rigortype 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) 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 +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  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 +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,335 @@
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 far the superclass walk climbs before giving up. A project hierarchy deeper than this is either
50
+ # pathological or cyclic, and a cycle is possible in a project's *as-written* table.
51
+ ANCESTRY_CAP = 24
52
+
53
+ NO_ROWS = {}.freeze
54
+ private_constant :NO_ROWS
55
+
56
+ # One compiled attribution row.
57
+ #
58
+ # `discharge` is the RESOLVED grant, not the manifest's request: {.build} has already demoted a
59
+ # non-first-party plugin's `true` to `false` and recorded a warning, so nothing downstream has to
60
+ # re-ask who may discharge.
61
+ Row = Data.define(:key, :labels, :narrow, :discharge, :within, :taint, :plugin_id) do
62
+ def discharge? = discharge
63
+ end
64
+
65
+ # One compiled framework-edge strategy: the base class a plugin pointed the engine at, and which
66
+ # plugin pointed.
67
+ Edge = Data.define(:target, :receiver, :selector, :plugin_id)
68
+
69
+ def self.empty
70
+ @empty ||= new(contributions: [], superclasses: NO_ROWS)
71
+ end
72
+
73
+ # @param plugin_registry [Rigor::Plugin::Registry, nil]
74
+ # @param superclasses [Hash{String=>String,Array<String>}] the project's as-written superclass table
75
+ # (`Scope::DiscoveryIndex#discovered_superclasses`). Empty is legal and simply means no row matches
76
+ # through inheritance.
77
+ def self.build(plugin_registry, superclasses: NO_ROWS)
78
+ contributions = plugin_registry&.effect_contributions || []
79
+ return empty if contributions.empty?
80
+
81
+ new(contributions: contributions, superclasses: superclasses)
82
+ rescue StandardError
83
+ empty
84
+ end
85
+
86
+ # Human-readable notes about contributions that were accepted only in part — a third-party plugin's
87
+ # `discharge: true` demoted, a label root refused. Surfaced by `rigor effects`; never a diagnostic,
88
+ # because a plugin the user chose is not the project's mistake to be flagged for.
89
+ attr_reader :warnings
90
+
91
+ # `{owner => [labels]}` — what {#extend_registry} folds into the run's vocabulary.
92
+ attr_reader :labels_by_owner
93
+
94
+ # Every `effect_entry_points:` preset across the loaded set, in registration order.
95
+ attr_reader :entry_points
96
+
97
+ def initialize(contributions:, superclasses:)
98
+ @warnings = []
99
+ @class_rows = {}
100
+ @path_rows = {}
101
+ @self_rows = {}
102
+ @result_rows = {}
103
+ @edges = []
104
+ @labels_by_owner = {}
105
+ @entry_points = []
106
+ contributions.each { |contribution| absorb(contribution) }
107
+ @superclasses = superclasses || NO_ROWS
108
+ @ancestry = {}
109
+ @digest = compute_digest
110
+ finalize
111
+ end
112
+
113
+ # A content digest of every compiled plugin fact — labels, attributions, edges and presets, each with
114
+ # the plugin that contributed it. {Identity} folds it in, so upgrading a plugin whose rows moved
115
+ # invalidates the effects slot exactly as a re-audited `data/effects/core.yml` row does. Deliberately
116
+ # independent of the project's superclass table, which is a *project* input the diagnostics identity
117
+ # already covers.
118
+ attr_reader :digest
119
+
120
+ def empty?
121
+ !attributions? && @edges.empty? && @labels_by_owner.empty? && @entry_points.empty?
122
+ end
123
+
124
+ # Whether any attribution row exists at all — the scan's fast path, asked once per call site.
125
+ def attributions?
126
+ !@class_rows.empty? || !@path_rows.empty? || !@self_rows.empty? || !@result_rows.empty?
127
+ end
128
+
129
+ def edges?
130
+ !@edges.empty?
131
+ end
132
+
133
+ # The row colouring `owner`'s `selector`, found on `owner` itself or on a project ancestor of it.
134
+ #
135
+ # @param owner [String, nil] the receiver's class name as the syntax or the typer named it
136
+ # @param singleton [Boolean] whether the call is `Owner.selector`
137
+ # @return [Row, nil]
138
+ def class_row(owner, singleton, selector)
139
+ return nil if owner.nil? || @class_rows.empty?
140
+
141
+ bucket = @class_rows[singleton]
142
+ return nil if bucket.nil?
143
+
144
+ ancestry(owner).each do |candidate|
145
+ row = bucket[candidate]&.[](selector)
146
+ return row if row
147
+ end
148
+ nil
149
+ end
150
+
151
+ # The row colouring `path`'s `selector`, where `path` is a receiver expression (`"Rails.cache"`).
152
+ # Exact — a receiver path names one object and has no ancestry to walk.
153
+ def path_row(path, selector)
154
+ return nil if path.nil? || @path_rows.empty?
155
+
156
+ @path_rows[path]&.[](selector)
157
+ end
158
+
159
+ # The row colouring `path`'s `selector` for a receiver rooted at implicit self (`"self.flash.now"`),
160
+ # inside a unit whose class is `owner_class`. Answers nil when the row's `within:` class is not on
161
+ # `owner_class`'s project ancestry — a receiver-less `session` outside a controller is a different
162
+ # `session`.
163
+ def self_path_row(path, selector, owner_class)
164
+ return nil if path.nil? || @self_rows.empty?
165
+
166
+ row = @self_rows[path]&.[](selector)
167
+ return nil if row.nil?
168
+ return nil unless descends_from?(owner_class, row.within)
169
+
170
+ row
171
+ end
172
+
173
+ # The row colouring `selector` on the RESULT of a call to `producer` (or to a project ancestor of it):
174
+ # `UserMailer.welcome(u).deliver_now`, `WelcomeJob.set(wait: 1.hour).perform_later`. The lazy object
175
+ # in between has no declared type; the class that made it is written in the source.
176
+ def result_row(producer, selector)
177
+ return nil if producer.nil? || @result_rows.empty?
178
+
179
+ ancestry(producer).each do |candidate|
180
+ row = @result_rows[candidate]&.[](selector)
181
+ return row if row
182
+ end
183
+ nil
184
+ end
185
+
186
+ # The framework-edge strategies of one kind, e.g. every `:activerecord_callbacks` base class the
187
+ # loaded plugins named.
188
+ def edges_for(target)
189
+ @edges.select { |edge| edge.target == target }
190
+ end
191
+
192
+ # Whether `class_name` is `ancestor`, or reaches it through the project's own `class … <` lines.
193
+ def descends_from?(class_name, ancestor)
194
+ return false if class_name.nil?
195
+
196
+ ancestry(class_name).include?(ancestor)
197
+ end
198
+
199
+ # `registry` extended with every plugin's `effect_labels:`, each under its own owner so
200
+ # {Registry#with} enforces root ownership per plugin rather than for the set as a whole. A refusal is
201
+ # recorded as a warning and that plugin's labels are dropped; the rest of the run keeps its
202
+ # vocabulary, because one plugin overreaching must not un-name another's labels.
203
+ def extend_registry(registry)
204
+ @labels_by_owner.each do |owner, labels|
205
+ registry = registry.with(labels: labels, owner: owner)
206
+ rescue Registry::Error => e
207
+ @warnings << "effect labels from #{owner.inspect} were not registered: #{e.message}"
208
+ end
209
+ registry
210
+ end
211
+
212
+ private
213
+
214
+ def absorb(contribution)
215
+ note_root_demotion(contribution)
216
+ (@labels_by_owner[contribution.owner] ||= []).concat(contribution.labels)
217
+ contribution.attributions.each { |entry| absorb_attribution(contribution, entry) }
218
+ contribution.edges.each do |entry|
219
+ @edges << Edge.new(target: entry.target, receiver: entry.receiver, selector: entry.method,
220
+ plugin_id: contribution.id)
221
+ end
222
+ @entry_points.concat(contribution.entry_points)
223
+ end
224
+
225
+ # ADR-103 WD2 — a plugin that asked to own a framework root and is not one the engine bundles keeps
226
+ # the root named after itself. Worth saying out loud: the labels still register, just under a
227
+ # different root, and a silent rename would look to the author like the labels vanished.
228
+ def note_root_demotion(contribution)
229
+ return if contribution.requested_root.nil? || contribution.requested_root == contribution.owner
230
+
231
+ @warnings << "plugin #{contribution.id.inspect} is not bundled with the engine and may not open " \
232
+ "the effect-label root #{contribution.requested_root.inspect}; its labels open " \
233
+ "#{contribution.owner.inspect} instead"
234
+ end
235
+
236
+ def absorb_attribution(contribution, entry)
237
+ row = Row.new(key: entry.key, labels: LabelSet.new(entry.labels), narrow: entry.narrow,
238
+ discharge: discharge_granted?(contribution, entry), within: entry.within,
239
+ taint: entry.taint, plugin_id: contribution.id)
240
+ (bucket_for(entry)[entry.receiver] ||= {})[entry.method.to_s] = row
241
+ end
242
+
243
+ # Which index a row lands in, from its receiver spelling.
244
+ def bucket_for(entry)
245
+ return @self_rows if entry.self_path?
246
+ return @path_rows if entry.receiver_path?
247
+ return @result_rows if entry.on_result
248
+
249
+ @class_rows[entry.singleton] ||= {}
250
+ end
251
+
252
+ # ADR-103 WD6 — discharge is a grant, and the granting fact is "the engine bundles this plugin".
253
+ def discharge_granted?(contribution, entry)
254
+ return false unless entry.discharge
255
+ return true if contribution.discharge_allowed
256
+
257
+ @warnings << "plugin #{contribution.id.inspect} is not bundled with the engine; its " \
258
+ "#{entry.key} attribution does not discharge the call site's taint"
259
+ false
260
+ end
261
+
262
+ # `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the table is *as written*,
263
+ # so a project can spell one that loops.
264
+ def ancestry(class_name)
265
+ @ancestry[class_name] ||= begin
266
+ chain = []
267
+ seen = Set.new
268
+ current = class_name
269
+ while current && seen.add?(current) && chain.length < ANCESTRY_CAP
270
+ chain << current
271
+ current = Array(@superclasses[current]).first
272
+ end
273
+ chain.freeze
274
+ end
275
+ end
276
+
277
+ def compute_digest
278
+ payload = [
279
+ @labels_by_owner.sort.map { |owner, labels| [owner, labels.sort] },
280
+ @class_rows.keys.sort_by { |singleton| singleton ? 1 : 0 }
281
+ .map { |singleton| [singleton, sorted(@class_rows[singleton])] },
282
+ sorted(@path_rows), sorted(@self_rows), sorted(@result_rows),
283
+ @edges.map { |edge| [edge.target.to_s, edge.receiver, edge.selector.to_s, edge.plugin_id] }.sort,
284
+ @entry_points.map(&:to_h).sort_by { |preset| preset["name"] }
285
+ ]
286
+ Digest::SHA256.hexdigest(payload.inspect)
287
+ end
288
+
289
+ # ADR-103 WD14 — `effects.snapshot.reach: [rails]` adopts a preset BY NAME, so the names have to be
290
+ # registered somewhere between plugin load and snapshot build. Here is that somewhere: this object is
291
+ # built once per process from the loaded plugin set, which is exactly the condition the registration
292
+ # needs. {EntryPoints.register} is idempotent for an identical glob set, so a second run in one
293
+ # process is a no-op; two plugins claiming one name with DIFFERENT globs is a genuine conflict and
294
+ # becomes a warning rather than taking the run down over a `reach:` key nobody may have used.
295
+ def register_entry_points
296
+ return if @entry_points.empty?
297
+
298
+ EntryPoints.register_all(@entry_points)
299
+ rescue EntryPoints::Error => e
300
+ @warnings << "entry-point preset not registered: #{e.message}"
301
+ end
302
+
303
+ def sorted(bucket)
304
+ bucket.sort.map do |receiver, rows|
305
+ [receiver,
306
+ rows.sort.map do |selector, row|
307
+ [selector, row.labels.to_a, row.narrow, row.discharge, row.within, row.taint]
308
+ end]
309
+ end
310
+ end
311
+
312
+ def finalize
313
+ @class_rows.each_value { |bucket| bucket.each_value(&:freeze) }
314
+ @class_rows.each_value(&:freeze)
315
+ @class_rows.freeze
316
+ @path_rows.each_value(&:freeze)
317
+ @path_rows.freeze
318
+ @self_rows.each_value(&:freeze)
319
+ @self_rows.freeze
320
+ @result_rows.each_value(&:freeze)
321
+ @result_rows.freeze
322
+ @edges.freeze
323
+ @labels_by_owner.each_value(&:uniq!)
324
+ @labels_by_owner.reject! { |_, labels| labels.empty? }
325
+ @labels_by_owner.freeze
326
+ @entry_points.freeze
327
+ register_entry_points
328
+ # `@warnings` and `@ancestry` stay mutable: the first collects the registry-extension refusals that
329
+ # can only be known when a vocabulary is folded, and the second is a per-process memo. The object is
330
+ # therefore not frozen — it is process-local by construction (each fork-pool worker builds its own),
331
+ # and nothing marshals it.
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,275 @@
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
+ edges = resolve_edges(collection)
57
+ state = seed(summaries, discharge)
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.
65
+ def resolve_edges(collection)
66
+ index = Index.new(collection)
67
+ collection.edges.each_with_object({}) do |(caller_key, list), out|
68
+ targets = list.flat_map { |edge| index.targets_for(edge) }.uniq.sort
69
+ out[caller_key] = targets.freeze unless targets.empty?
70
+ end
71
+ end
72
+
73
+ # Causes are carried as a Set through the fixpoint and flattened back to a sorted Array in
74
+ # {build_entries}. A Set is what {absorb} needs: unioning one along an edge must cost the source's
75
+ # size and allocate NOTHING when it adds nothing, and the array-concat-and-uniq it replaces
76
+ # allocated twice on every visit of every edge.
77
+ def seed(summaries, discharge)
78
+ summaries.transform_values do |summary|
79
+ {
80
+ proven: summary.proven,
81
+ undischarged: discharge.inert? ? summary.proven : discharge.undischarged(summary.bundles),
82
+ declared: summary.declared,
83
+ exhaustive: summary.exhaustive?, causes: Set.new(summary.causes)
84
+ }
85
+ end
86
+ end
87
+
88
+ # A worklist to a fixpoint. `state[key]` changing can only change the methods that CALL `key`, so a
89
+ # pass re-visits exactly those and the round-robin over the whole table is gone — that walk cost
90
+ # O(passes × edges) and the passes are the graph's depth.
91
+ #
92
+ # Each pass still runs in sorted key order, so the answer does not depend on Hash insertion order
93
+ # and a pooled run agrees with a sequential one bit for bit. (The lattice is finite and every step
94
+ # monotone, so the least fixpoint is unique and visit order cannot change it; the sorted pass keeps
95
+ # the *work* reproducible too.)
96
+ def iterate(state, edges)
97
+ order = state.keys.sort
98
+ callers = reverse_edges(edges)
99
+ pending = nil
100
+
101
+ loop do
102
+ dirty = nil
103
+ order.each do |key|
104
+ next if pending && !pending.include?(key)
105
+
106
+ list = edges[key]
107
+ next if list.nil?
108
+
109
+ changed = false
110
+ list.each { |callee| changed = true if absorb(state, key, callee) }
111
+ next unless changed
112
+
113
+ (dirty ||= Set.new).merge(callers[key]) if callers.key?(key)
114
+ end
115
+ break if dirty.nil?
116
+
117
+ pending = dirty
118
+ end
119
+ end
120
+
121
+ # `{callee_key => [caller_key]}` — who has to be re-visited when a key's closure moves.
122
+ def reverse_edges(edges)
123
+ edges.each_with_object({}) do |(caller_key, list), out|
124
+ list.each { |callee| (out[callee] ||= []) << caller_key }
125
+ end
126
+ end
127
+
128
+ def absorb(state, key, callee)
129
+ target = state[key]
130
+ source = state[callee]
131
+ return false if source.nil? || target.equal?(source)
132
+
133
+ # Each lane is named literally rather than looped over an array: this runs once per edge per
134
+ # visit, and a literal array of lane names would allocate one per call for nothing.
135
+ changed = join_lane(target, source, :proven)
136
+ changed = true if join_lane(target, source, :undischarged)
137
+ changed = true if join_lane(target, source, :declared)
138
+ if target[:exhaustive] && !source[:exhaustive]
139
+ target[:exhaustive] = false
140
+ changed = true
141
+ end
142
+ causes = target[:causes]
143
+ source[:causes].each { |cause| changed = true if causes.add?(cause) }
144
+ changed
145
+ end
146
+
147
+ # Joins one label lane in place along an edge, answering whether it moved. {LabelSet#join} returns
148
+ # the receiver untouched when the source adds nothing, so a converged region costs a comparison and
149
+ # no allocation at all.
150
+ def join_lane(target, source, lane)
151
+ joined = target[lane].join(source[lane])
152
+ moved = joined != target[lane]
153
+ target[lane] = joined if moved
154
+ moved
155
+ end
156
+
157
+ def build_entries(summaries, edges, state)
158
+ summaries.each_with_object({}) do |(key, summary), out|
159
+ closed = state.fetch(key)
160
+ out[key] = EffectTable::Entry.new(
161
+ key: key,
162
+ direct: summary,
163
+ proven: closed[:proven],
164
+ undischarged: closed[:undischarged],
165
+ declared: closed[:declared],
166
+ exhaustive: closed[:exhaustive],
167
+ causes: closed[:causes].sort_by { |cause, detail| [cause, detail.to_s] }.freeze,
168
+ edges: edges.fetch(key, NO_EDGES)
169
+ )
170
+ end
171
+ end
172
+
173
+ private_class_method :resolve_edges, :seed, :iterate, :reverse_edges, :absorb, :join_lane,
174
+ :build_entries
175
+
176
+ # The class graph a run's collections describe, and the edge resolution over it. Built once per
177
+ # propagation; every lookup is a Hash read.
178
+ class Index
179
+ def initialize(collection)
180
+ @summaries = collection.summaries
181
+ @superclasses = collection.superclasses
182
+ @includes = collection.includes
183
+ @classes = build_classes(collection)
184
+ @descendants = build_descendants(collection.superclasses)
185
+ @descendant_closures = {}
186
+ @targets = {}
187
+ end
188
+
189
+ # Every project method key `edge` may reach: the definition its ancestry resolves to, plus every
190
+ # override of the same selector in a project subclass of the receiver's class.
191
+ #
192
+ # Memoised on `(receiver class, kind, selector)` — the answer depends on nothing else, and one
193
+ # such triple is asked for once per call site in the project. `ApplicationRecord#save` alone is
194
+ # thousands of sites on a Rails app, each of which used to re-walk the whole subclass forest.
195
+ def targets_for(edge)
196
+ @targets[[edge.receiver_class, edge.kind, edge.selector]] ||= begin
197
+ separator = edge.kind == :singleton ? "." : "#"
198
+ targets = []
199
+ owner = resolve_owner(edge.receiver_class, separator, edge.selector)
200
+ targets << owner if owner
201
+ descendant_closure(edge.receiver_class).each do |subclass|
202
+ key = "#{subclass}#{separator}#{edge.selector}"
203
+ targets << key if @summaries.key?(key)
204
+ end
205
+ targets.freeze
206
+ end
207
+ end
208
+
209
+ private
210
+
211
+ # The transitive subclass closure, memoised per class. A deep hierarchy's root is asked for it
212
+ # once, not once per selector reaching it.
213
+ def descendant_closure(class_name)
214
+ @descendant_closures[class_name] ||= descendants_of(class_name).freeze
215
+ end
216
+
217
+ # Ancestry order mirrors the engine's: the class itself, the modules it includes, then its
218
+ # superclass, recursively. Cycle-guarded, because a project may declare one. Ancestry names
219
+ # arrive as as-written candidate lists (see `Scanner#lexical_candidates`); every candidate is
220
+ # enqueued and the most-qualified one comes first, so the right constant wins the race and a
221
+ # spelling that names nothing simply matches no key.
222
+ def resolve_owner(class_name, separator, selector)
223
+ seen = Set.new
224
+ queue = [class_name]
225
+ until queue.empty?
226
+ current = queue.shift
227
+ next if current.nil? || !seen.add?(current)
228
+
229
+ key = "#{current}#{separator}#{selector}"
230
+ return key if @summaries.key?(key)
231
+
232
+ queue.concat(@includes.fetch(current, []))
233
+ queue.concat(@superclasses.fetch(current, []))
234
+ end
235
+ nil
236
+ end
237
+
238
+ def descendants_of(class_name)
239
+ collected = []
240
+ queue = @descendants.fetch(class_name, []).dup
241
+ seen = Set.new
242
+ until queue.empty?
243
+ current = queue.shift
244
+ next unless seen.add?(current)
245
+
246
+ collected << current
247
+ queue.concat(@descendants.fetch(current, []))
248
+ end
249
+ collected
250
+ end
251
+
252
+ # The subclass index the closed-world override join walks. Unlike the ancestor walk, this one
253
+ # must pick **one** parent per child: enqueuing every candidate would let `A::Base` and `B::Base`
254
+ # share the short spelling `Base` and join an unrelated class's override into the proven lane.
255
+ # The most-qualified candidate the project actually defines wins; a child whose parent is outside
256
+ # the project keeps its first (most-qualified) spelling and simply matches nothing.
257
+ def build_descendants(superclasses)
258
+ superclasses.each_with_object({}) do |(child, candidates), out|
259
+ parent = candidates.find { |candidate| @classes.include?(candidate) } || candidates.first
260
+ (out[parent] ||= []) << child
261
+ end
262
+ end
263
+
264
+ # Every class name the project defines a method on — the evidence `build_descendants` resolves an
265
+ # as-written superclass against.
266
+ def build_classes(collection)
267
+ collection.summaries.each_key.with_object(Set.new) do |key, out|
268
+ index = key.index("#") || key.index(".")
269
+ out << key[0, index] if index
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end