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