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.
Files changed (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. 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