rigortype 0.3.4 → 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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/docs/manual/02-cli-reference.md +60 -13
  6. data/docs/manual/03-configuration.md +20 -3
  7. data/docs/manual/04-diagnostics.md +3 -2
  8. data/docs/manual/11-ci.md +37 -0
  9. data/docs/manual/12-caching.md +39 -0
  10. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  11. data/docs/manual/19-effect-labels.md +671 -0
  12. data/docs/manual/README.md +5 -0
  13. data/docs/manual/ci-templates/README.md +9 -0
  14. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  15. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  16. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  17. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  18. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  19. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  20. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  21. data/lib/rigor/analysis/runner.rb +12 -3
  22. data/lib/rigor/analysis/worker_session.rb +3 -1
  23. data/lib/rigor/cli/effects_command.rb +123 -9
  24. data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
  25. data/lib/rigor/cli/effects_renderer.rb +41 -3
  26. data/lib/rigor/cli/effects_report.rb +116 -6
  27. data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
  28. data/lib/rigor/cli.rb +12 -1
  29. data/lib/rigor/configuration.rb +37 -25
  30. data/lib/rigor/configuration_error.rb +20 -0
  31. data/lib/rigor/effects/collector.rb +38 -1
  32. data/lib/rigor/effects/entry_points.rb +47 -1
  33. data/lib/rigor/effects/file_collection.rb +18 -4
  34. data/lib/rigor/effects/framework_units.rb +68 -13
  35. data/lib/rigor/effects/inline_anchor.rb +134 -0
  36. data/lib/rigor/effects/plugin_facts.rb +62 -13
  37. data/lib/rigor/effects/propagator.rb +79 -19
  38. data/lib/rigor/effects/registry.rb +10 -3
  39. data/lib/rigor/effects/scanner.rb +21 -9
  40. data/lib/rigor/effects/signature_sources.rb +16 -0
  41. data/lib/rigor/effects/snapshot.rb +21 -5
  42. data/lib/rigor/effects/taint_cause.rb +1 -0
  43. data/lib/rigor/effects/unit_scan.rb +87 -12
  44. data/lib/rigor/plugin/base.rb +4 -0
  45. data/lib/rigor/plugin/box.rb +18 -2
  46. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  47. data/lib/rigor/plugin/manifest.rb +34 -10
  48. data/lib/rigor/plugin/registry.rb +10 -3
  49. data/lib/rigor/rbs_extended.rb +22 -2
  50. data/lib/rigor/version.rb +1 -1
  51. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  52. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  53. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  54. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  55. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  56. metadata +6 -1
@@ -53,23 +53,49 @@ module Rigor
53
53
  return EffectTable.empty if collection.summaries.empty?
54
54
 
55
55
  summaries = collection.summaries
56
- edges = resolve_edges(collection)
57
56
  state = seed(summaries, discharge)
57
+ edges = resolve_edges(collection, state)
58
58
  iterate(state, edges)
59
59
  EffectTable.new(build_entries(summaries, edges, state))
60
60
  rescue StandardError
61
61
  EffectTable.empty
62
62
  end
63
63
 
64
- # `{caller_key => [callee_key]}`, sorted and de-duplicated.
65
- def resolve_edges(collection)
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)
66
67
  index = Index.new(collection)
67
68
  collection.edges.each_with_object({}) do |(caller_key, list), out|
68
- targets = list.flat_map { |edge| index.targets_for(edge) }.uniq.sort
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
69
74
  out[caller_key] = targets.freeze unless targets.empty?
70
75
  end
71
76
  end
72
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
+
73
99
  # Causes are carried as a Set through the fixpoint and flattened back to a sorted Array in
74
100
  # {build_entries}. A Set is what {absorb} needs: unioning one along an edge must cost the source's
75
101
  # size and allocate NOTHING when it adds nothing, and the array-concat-and-uniq it replaces
@@ -170,8 +196,8 @@ module Rigor
170
196
  end
171
197
  end
172
198
 
173
- private_class_method :resolve_edges, :seed, :iterate, :reverse_edges, :absorb, :join_lane,
174
- :build_entries
199
+ private_class_method :resolve_edges, :taint_unresolved_super, :seed, :iterate, :reverse_edges,
200
+ :absorb, :join_lane, :build_entries
175
201
 
176
202
  # The class graph a run's collections describe, and the edge resolution over it. Built once per
177
203
  # propagation; every lookup is a Hash read.
@@ -189,25 +215,43 @@ module Rigor
189
215
  # Every project method key `edge` may reach: the definition its ancestry resolves to, plus every
190
216
  # override of the same selector in a project subclass of the receiver's class.
191
217
  #
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
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
194
220
  # thousands of sites on a Rails app, each of which used to re-walk the whole subclass forest.
195
221
  def targets_for(edge)
196
- @targets[[edge.receiver_class, edge.kind, edge.selector]] ||= begin
222
+ @targets[[edge.receiver_class, edge.kind, edge.selector, edge.super_call]] ||= begin
197
223
  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
224
+ edge.super_call ? super_targets(edge, separator) : call_targets(edge, separator)
206
225
  end
207
226
  end
208
227
 
209
228
  private
210
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
+
211
255
  # The transitive subclass closure, memoised per class. A deep hierarchy's root is asked for it
212
256
  # once, not once per selector reaching it.
213
257
  def descendant_closure(class_name)
@@ -220,8 +264,24 @@ module Rigor
220
264
  # enqueued and the most-qualified one comes first, so the right constant wins the race and a
221
265
  # spelling that names nothing simply matches no key.
222
266
  def resolve_owner(class_name, separator, selector)
223
- seen = Set.new
224
- queue = [class_name]
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)
225
285
  until queue.empty?
226
286
  current = queue.shift
227
287
  next if current.nil? || !seen.add?(current)
@@ -74,14 +74,16 @@ module Rigor
74
74
  new(
75
75
  vocabulary_version: raw.fetch("vocabulary", 0),
76
76
  labels: raw.fetch("labels", nil) || [],
77
- retired: raw.fetch("retired", nil) || {}
77
+ retired: raw.fetch("retired", nil) || {},
78
+ descriptions: raw.fetch("descriptions", nil) || {}
78
79
  )
79
80
  end
80
81
 
81
82
  attr_reader :vocabulary_version
82
83
 
83
- def initialize(vocabulary_version:, labels:, retired: {})
84
+ def initialize(vocabulary_version:, labels:, retired: {}, descriptions: {})
84
85
  @vocabulary_version = vocabulary_version
86
+ @descriptions = descriptions.to_h { |root, text| [root.to_s, text.to_s.gsub(/\s+/, " ").strip] }.freeze
85
87
  @labels = labels.map(&:to_s).uniq.sort.freeze
86
88
  @known = build_known(@labels)
87
89
  @roots = @known.select { |label| Label.parent(label).nil? }.sort.freeze
@@ -96,6 +98,10 @@ module Rigor
96
98
  # The roots of the vocabulary — the outermost segments {#with} treats as already owned.
97
99
  attr_reader :roots
98
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
+
99
105
  # Whether the vocabulary recognises `label`: an exact row, or an ancestor of one. A declared
100
106
  # `io` is recognised because `io.net` exists, so a bound may name an interior node the data
101
107
  # file never spells out on its own line.
@@ -142,7 +148,8 @@ module Rigor
142
148
 
143
149
  check_ownership(label, owner)
144
150
  end
145
- self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired)
151
+ self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired,
152
+ descriptions: @descriptions)
146
153
  end
147
154
 
148
155
  private
@@ -116,12 +116,20 @@ module Rigor
116
116
  end
117
117
 
118
118
  def enter_def(node, prefix, singleton)
119
- instance_method = !singleton && node.receiver.nil?
120
- harvest_for(prefix)[:defs] << node.name.to_s if @harvest && instance_method && !prefix.empty?
121
- add_unit(
122
- class_name_for(prefix), node.name.to_s, singleton || !node.receiver.nil?,
123
- node.body, node.parameters
124
- )
119
+ own_singleton = singleton || !node.receiver.nil?
120
+ scan = add_unit(class_name_for(prefix), node.name.to_s, own_singleton, node.body, node.parameters)
121
+ harvest_def(prefix, node.name.to_s, own_singleton, scan) if @harvest && !prefix.empty?
122
+ end
123
+
124
+ # What the framework strategies need to know about a `def` the class body spelled out itself: that it
125
+ # exists, and whether it reaches `super`. `:defs` stays instance-only, because a mailer action is an
126
+ # instance method; `:units` is keyed by the suffix a synthetic unit key carries, so both sides of the
127
+ # `#` / `.` split are answerable (#440). A body the collector could not finish reads as delegating,
128
+ # which keeps the framework's claim — the fail-soft direction for an upper bound.
129
+ def harvest_def(prefix, name, singleton, scan)
130
+ entry = harvest_for(prefix)
131
+ entry[:defs] << name unless singleton
132
+ entry[:units]["#{singleton ? '.' : '#'}#{name}"] = scan.nil? || scan.delegates_upward?
125
133
  end
126
134
 
127
135
  # ADR-103 WD10 — a receiver-less call in a class body, recorded as `macro => [literal symbol
@@ -156,7 +164,7 @@ module Rigor
156
164
  end
157
165
 
158
166
  def harvest_for(prefix)
159
- @harvest[class_name_for(prefix)] ||= { defs: [], macros: {}, uniqueness: false }
167
+ @harvest[class_name_for(prefix)] ||= { defs: [], units: {}, macros: {}, uniqueness: false }
160
168
  end
161
169
 
162
170
  # Files the units the framework contributes for each class this file declares. Runs after the walk,
@@ -168,7 +176,7 @@ module Rigor
168
176
  @harvest.each do |class_name, entry|
169
177
  FrameworkUnits.synthesize(
170
178
  class_name: class_name, instance_methods: entry[:defs], macros: entry[:macros],
171
- uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts
179
+ uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts, own_units: entry[:units]
172
180
  ).each { |key, summary, edges| merge_unit(key, summary, edges) }
173
181
  end
174
182
  end
@@ -176,6 +184,8 @@ module Rigor
176
184
  # Scans one unit and files its summary, then recurses into the units its body declared. Fail-soft
177
185
  # per unit (ADR-103 WD13): a unit the scanner cannot finish is recorded as non-exhaustive with
178
186
  # `collector-error` and its siblings are unaffected.
187
+ #
188
+ # @return [UnitScan, nil] the finished scan, or nil when the unit failed soft
179
189
  def add_unit(class_name, method_name, singleton, body, parameters)
180
190
  key = "#{class_name}#{singleton ? '.' : '#'}#{method_name}"
181
191
  names = parameter_names(parameters)
@@ -184,15 +194,17 @@ module Rigor
184
194
  block_parameter: block_parameter_name(parameters),
185
195
  owned_locals: LocalOwnership.owned(body, names), calls: @calls,
186
196
  attribution: @attribution, envelopes: @envelopes, plugin_facts: @plugin_facts,
187
- owner_class: class_name
197
+ owner_class: class_name, method_name: method_name
188
198
  )
189
199
  summary, edges = scan.run(body)
190
200
  merge_unit(key, summary, edges)
191
201
  scan.nested.each do |name, nested_singleton, nested_body, nested_parameters|
192
202
  add_unit(class_name, name, singleton || nested_singleton, nested_body, nested_parameters)
193
203
  end
204
+ scan
194
205
  rescue StandardError
195
206
  merge_unit(key, Summary.tainted("collector-error", method_name), [])
207
+ nil
196
208
  end
197
209
 
198
210
  # A receiver-less call in a class / module body that declares units or ancestry. Class bodies are
@@ -58,6 +58,22 @@ module Rigor
58
58
  signature_paths.nil? || signature_paths.empty? ? DEFAULT_ROOTS : signature_paths
59
59
  end
60
60
 
61
+ # The FIRST annotated virtual entry, as a `collect`-shaped one-element array (or an empty one).
62
+ #
63
+ # #441 — the analysis paths use this to hand the annotations-unchecked residual the inline stratum
64
+ # of a run that resolved an environment but never stored it. Reduced to one entry on purpose: the
65
+ # residual reports one annotation per run, so the carrier a run holds past the environment's own
66
+ # lifetime is a single synthesized buffer rather than the project's whole virtual tree.
67
+ #
68
+ # @param virtual_rbs [Array<Array(String, String)>, nil]
69
+ # @return [Array<Array(String, String)>] zero or one pair.
70
+ def annotated_carrier(virtual_rbs)
71
+ Array(virtual_rbs).each do |name, content|
72
+ return [[name.to_s, content.to_s]] if ANNOTATION_HINT.match?(content)
73
+ end
74
+ [].freeze
75
+ end
76
+
61
77
  # The first `[name, content, line]` carrying an effect annotation, or nil. Line numbers are
62
78
  # 1-based and count into `content`, which for a `virtual:` buffer is the SYNTHESIZED text — the
63
79
  # caller re-anchors it against the Ruby file when that matters.
@@ -164,11 +164,7 @@ module Rigor
164
164
  entries.flat_map do |entry|
165
165
  next [entry] if EntryPoints.glob?(entry)
166
166
 
167
- EntryPoints.globs_for(entry) ||
168
- raise(EntryPoints::Error,
169
- "effects.snapshot.reach names no registered entry-point preset: #{entry.inspect} " \
170
- "(registered: #{EntryPoints.names.inspect}; a preset is named by the plugin that " \
171
- "models the framework, so listing that plugin is what registers it)")
167
+ EntryPoints.resolve!(entry)
172
168
  end.uniq.sort.freeze
173
169
  end
174
170
 
@@ -335,6 +331,25 @@ module Rigor
335
331
  # Renders the file. Hand-rolled rather than `YAML.dump`ed so the leading comment, the key order and
336
332
  # the flow sequences are ours: two runs over one tree must be byte-identical, and a snapshot written
337
333
  # by a pooled run must equal the sequential one bit for bit.
334
+ # The hint goes in the artefact, not only in the run's output (#436): `reach:` is the half of the
335
+ # snapshot that answers what an entry point *causes*, and a project that never configured one
336
+ # commits the less interesting half with no way to know it. The run also names the presets its own
337
+ # plugin set registered, which this file cannot — a committed artefact must not carry a list that
338
+ # goes stale when the plugin list changes.
339
+ EMPTY_REACH_NOTE = [
340
+ "",
341
+ "# `reach:` is empty. It records the TRANSITIVE footprint at your entry points — what a",
342
+ "# controller action or a job causes, rather than what its own body does — and it is the half of",
343
+ "# this file most worth reviewing. Fill it in by naming entry points in `.rigor.yml`:",
344
+ "#",
345
+ "# effects:",
346
+ "# snapshot:",
347
+ "# reach: [rails] # a preset a plugin registered, or a project-relative file glob",
348
+ "#",
349
+ "# `rigor effects update` prints the presets this project's plugins actually register."
350
+ ].freeze
351
+ private_constant :EMPTY_REACH_NOTE
352
+
338
353
  def to_yaml
339
354
  lines = [HEADER]
340
355
  lines << "schema: #{@header.fetch('schema')}"
@@ -342,6 +357,7 @@ module Rigor
342
357
  lines << "vocabulary: #{@header.fetch('vocabulary')}"
343
358
  lines << "config_digest: #{scalar(@header.fetch('config_digest'))}"
344
359
  render_table(lines, "methods", @methods)
360
+ lines.concat(EMPTY_REACH_NOTE) if @reach.empty?
345
361
  render_table(lines, "reach", @reach)
346
362
  "#{lines.join("\n")}\n"
347
363
  end
@@ -18,6 +18,7 @@ module Rigor
18
18
  dynamic-send
19
19
  method-missing
20
20
  unresolved-self-call
21
+ unresolved-super
21
22
  opaque-callable
22
23
  unknown-ownership
23
24
  plugin-attribution
@@ -110,9 +110,12 @@ module Rigor
110
110
  # @param plugin_facts [PluginFacts] the loaded plugins' `effect_attributions:` (#387)
111
111
  # @param owner_class [String, nil] the class this unit is defined on — the carrier an
112
112
  # implicit-self call's envelope is looked up under, since the syntax spells `Kernel#name`
113
+ # @param method_name [String, nil] this unit's own selector — what a `super` in its body names as
114
+ # the target the propagator resolves above `owner_class` (#446). With no name to state, a `super`
115
+ # taints instead.
113
116
  def initialize(singleton:, parameters:, block_parameter:, owned_locals:, calls:, # rubocop:disable Metrics/ParameterLists
114
117
  attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
115
- plugin_facts: PluginFacts.empty, owner_class: nil)
118
+ plugin_facts: PluginFacts.empty, owner_class: nil, method_name: nil)
116
119
  @singleton = singleton
117
120
  @block_parameter = block_parameter
118
121
  @calls = calls
@@ -120,6 +123,7 @@ module Rigor
120
123
  @envelopes = envelopes
121
124
  @plugin_facts = plugin_facts
122
125
  @owner_class = owner_class
126
+ @method_name = method_name
123
127
  @mutation = MutationClassifier.new(
124
128
  singleton: singleton, parameters: parameters, owned_locals: owned_locals
125
129
  )
@@ -128,12 +132,24 @@ module Rigor
128
132
  @causes = []
129
133
  @edges = []
130
134
  @nested = []
135
+ @delegates_upward = false
131
136
  end
132
137
 
133
138
  # Units discovered inside this one — a nested `def`, or a `define_method` with a literal name whose
134
139
  # block becomes that method's body. Each is `[name, singleton, body_node, parameters_node]`.
135
140
  attr_reader :nested
136
141
 
142
+ # Whether this body reaches `super` — an override that delegates upward still runs whatever the
143
+ # superclass does. Nested `def`s are unit boundaries, so a `super` counted here is this unit's.
144
+ #
145
+ # A separate reading of the same node from the edge {#visit_super} records: what this bit answers is
146
+ # whether a framework's claim about the selector survives the class having written a body for it
147
+ # ({FrameworkUnits.replaced?}, #440), which is a question about the *class*, not about what the
148
+ # parent implementation does.
149
+ def delegates_upward?
150
+ @delegates_upward
151
+ end
152
+
137
153
  # Walks `body` and returns `[Summary, edges]`.
138
154
  def run(body)
139
155
  walk(body)
@@ -214,9 +230,31 @@ module Rigor
214
230
  when Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
215
231
  Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
216
232
  classify_mutation(node.receiver)
233
+ when Prism::SuperNode, Prism::ForwardingSuperNode
234
+ visit_super
217
235
  end
218
236
  end
219
237
 
238
+ # `super` in every shape — bare, `super()`, `super(args)`, and each of those inside a block or a
239
+ # rescue, since the walk descends into both (#446).
240
+ #
241
+ # It is a dispatch and must contribute like one. What the scan can settle from the syntax is the
242
+ # *identity* of the target — this unit's own class and selector — and nothing more: which definition
243
+ # sits above the class is a question about the whole project, so it goes out as an edge and the
244
+ # propagator resolves it against the merged ancestry, tainting when nothing there answers.
245
+ #
246
+ # A unit with no class or no name has no target to state. That is not a shape the scanner produces,
247
+ # and the taint is what a "cannot say" must read as rather than a silently dropped call.
248
+ def visit_super
249
+ @delegates_upward = true
250
+ return taint("unresolved-super", @method_name) if @owner_class.nil? || @method_name.nil?
251
+
252
+ @edges << FileCollection::Edge.new(
253
+ receiver_class: @owner_class, kind: @singleton ? :singleton : :instance,
254
+ selector: @method_name, self_call: true, super_call: true
255
+ )
256
+ end
257
+
220
258
  def visit_call(node)
221
259
  record = @calls[node]
222
260
  attribute(node, record)
@@ -285,14 +323,30 @@ module Rigor
285
323
 
286
324
  # The class whose call produced this receiver, for an `on_result:` row: the constant in
287
325
  # `UserMailer.welcome(u).deliver_now`, the receiver class the typer had otherwise. Nil unless the
288
- # receiver is itself a call with a receiver — a bare local variable says nothing about what made it.
326
+ # receiver is itself a call — a bare local variable says nothing about what made it.
327
+ #
328
+ # Two shapes beyond the constant, because between them they are how mail is actually sent (#456):
329
+ #
330
+ # - **an implicit-self producing call** — `issue_add(user, issue).deliver_later`, which is what a
331
+ # mailer's own `def self.deliver_issue_add` wrapper looks like in Redmine, 97 call sites of it.
332
+ # The producer is the unit's own class, which is exactly what an unqualified name resolves
333
+ # against;
334
+ # - **a builder one link further out** — `AdminMailer.with(recipient: a).new_trends(…).deliver_later!`,
335
+ # ActionMailer's parameterized form and the spelling Mastodon uses. `with` returns a lazy
336
+ # `Parameterized::Mailer` no project declares a type for, so the constant is two calls away rather
337
+ # than one, and stopping at the first link found nothing.
338
+ #
339
+ # The walk is the same heuristic the `on_result:` row already is — "the class that produced this" —
340
+ # applied transitively rather than once. A row still has to match the producer's ancestry AND the
341
+ # selector, so a chain that ends somewhere unrelated contributes nothing rather than a wrong label.
289
342
  def producer_class(receiver)
290
343
  return nil unless receiver.is_a?(Prism::CallNode)
291
344
 
292
345
  inner = receiver.receiver
293
- return nil if inner.nil?
346
+ return @owner_class if inner.nil?
294
347
 
295
- Source::ConstantPath.qualified_name(inner) || @calls[receiver]&.receiver_class
348
+ Source::ConstantPath.qualified_name(inner) || producer_class(inner) ||
349
+ @calls[receiver]&.receiver_class
296
350
  end
297
351
 
298
352
  # The receiver expression as the syntax spells it — `"Rails.cache"`, `"self.flash.now"` — or nil when
@@ -379,12 +433,12 @@ module Rigor
379
433
  # receiver mutation; `Time#localtime` is both). A **posture** states nothing about this selector,
380
434
  # so the uncatalogued path's own mutation rule applies to it unchanged.
381
435
  def claimed_by_catalogue?(node, record)
382
- owner, singleton, implicit = catalog_target(node, record)
436
+ owner, singleton, implicit, named = catalog_target(node, record)
383
437
  return false if owner.nil?
384
438
 
385
439
  entry = Catalog.default.lookup(
386
440
  owner, node.name.to_s, singleton: singleton, call_node: node,
387
- posture: posture_allowed?(node, record, implicit)
441
+ posture: posture_allowed?(node, record, implicit, named)
388
442
  )
389
443
  return false if entry.nil?
390
444
 
@@ -426,8 +480,26 @@ module Rigor
426
480
  # it would be a proven label with nothing proving it (the `dynamic-receiver` taint is the right
427
481
  # answer, and the uncatalogued path records it);
428
482
  # - `send` / `call`, whose own taints are the more specific reading ({DEFERRED_SELECTORS}).
429
- def posture_allowed?(node, record, implicit)
430
- !implicit && !record&.dynamic && !DEFERRED_SELECTORS.include?(node.name)
483
+ #
484
+ # **Unless the syntax named the class** (#463). The `Dynamic` exclusion exists because a projected
485
+ # receiver class is a guess; a written constant path is not one, and {#catalog_target} already
486
+ # trusts it for the row lookup. Refusing the posture there made the catalogue's reach a function of
487
+ # whether the bundled rbs happens to ship a signature for the constant: `Net::HTTP.get` proves
488
+ # `io.net.http` and `Net::IMAP.new` proved nothing, because rbs ships `net-http` and not
489
+ # `net-imap`. That is why the `Net::FTP` row has never once fired, and why Redmine's IMAP and POP3
490
+ # pollers proved no network at all.
491
+ #
492
+ # The site is then **claimed**, and so discharges, like every other catalogue answer. That is not a
493
+ # softening of the `dynamic-receiver` rule but its premise failing: the taint says the class the
494
+ # typer projected is a guess, and on a constant path there is no projection — the receiver of
495
+ # `Net::IMAP.new` is that constant, exactly. What the posture asserts on top is the hand-audited
496
+ # claim the catalogue is made of, the same one `Socket#connect` rests on when the typer did name
497
+ # the receiver. `implicit` and {DEFERRED_SELECTORS} keep their exclusions, which are about what the
498
+ # syntax means rather than about what the typer knew.
499
+ def posture_allowed?(node, record, implicit, named)
500
+ return false if implicit || DEFERRED_SELECTORS.include?(node.name)
501
+
502
+ named || !record&.dynamic
431
503
  end
432
504
 
433
505
  def visit_uncatalogued(node, record, bound = nil)
@@ -550,18 +622,21 @@ module Rigor
550
622
  # The class the catalogue would look this call up under, as `[owner, singleton, implicit_self]`.
551
623
  # Spelled from the syntax where the syntax settles it and from the typer's receiver otherwise;
552
624
  # `[nil, …]` when neither does.
625
+ # `[owner, singleton, implicit, named]`. `named` says the owner came from a **constant path the
626
+ # author wrote**, which is what licenses the posture on a receiver the typer could not resolve
627
+ # ({#posture_allowed?}).
553
628
  def catalog_target(node, record)
554
629
  receiver = node.receiver
555
- return ["Kernel", false, true] if receiver.nil? || receiver.is_a?(Prism::SelfNode)
630
+ return ["Kernel", false, true, false] if receiver.nil? || receiver.is_a?(Prism::SelfNode)
556
631
 
557
632
  constant = Source::ConstantPath.qualified_name(receiver)
558
- return [constant, !Catalog.default.object_constant?(constant), false] if constant
633
+ return [constant, !Catalog.default.object_constant?(constant), false, true] if constant
559
634
  return NO_TARGET if record.nil? || record.receiver_class.nil?
560
635
 
561
- [record.receiver_class, record.kind == :singleton, false]
636
+ [record.receiver_class, record.kind == :singleton, false, false]
562
637
  end
563
638
 
564
- NO_TARGET = [nil, false, false].freeze
639
+ NO_TARGET = [nil, false, false, false].freeze
565
640
  private_constant :NO_TARGET
566
641
 
567
642
  def positional_arity(node)
@@ -734,6 +734,10 @@ module Rigor
734
734
  manifest.effect_entry_points
735
735
  end
736
736
 
737
+ def effect_ancestry
738
+ manifest.effect_ancestry
739
+ end
740
+
737
741
  # ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}. Memoised so the boundary's accumulated `FileEntry`
738
742
  # rows persist across producer invocations within the same plugin instance and feed cache invalidation
739
743
  # via `cache_for`.
@@ -32,14 +32,30 @@ module Rigor
32
32
  # Requires `feature` into the shared box exactly once. Returns true when the feature is available in the
33
33
  # box, false when it could not be loaded (the caller then declines — never falls back to loading into the
34
34
  # main space, which would defeat the boundary).
35
+ #
36
+ # The rescue is `::`-qualified and covers `::ScriptError`, matching {Isolation::Process.run_worker_loop}:
37
+ # a failed box require raises `::LoadError` — a ScriptError, not a StandardError — and a plain
38
+ # `rescue StandardError` lets it escape and abort the whole run instead of the clean decline.
35
39
  def require_feature(feature)
36
40
  @required ||= {}
37
41
  return @required[feature] if @required.key?(feature)
38
42
 
39
43
  shared.require(feature)
40
44
  @required[feature] = true
41
- rescue StandardError
42
- @required[feature] = false
45
+ rescue ::StandardError, ::ScriptError
46
+ @required[feature] = eval_require(feature)
47
+ end
48
+
49
+ # The gem-resolving fallback: `Ruby::Box#require` resolves against the raw `$LOAD_PATH` only (it
50
+ # calls the C-level require directly), so a target library installed as a gem is reachable only
51
+ # through the box's own RubyGems — the `Kernel#require` *inside* the box. `feature` is a fixed,
52
+ # plugin-declared name rendered via `String#inspect` (a safe Ruby literal), so the eval carries no
53
+ # free input. Returns false when the box cannot load the feature either way (the caller declines).
54
+ def eval_require(feature)
55
+ shared.eval("require #{feature.inspect}") # require "active_support/inflector"
56
+ true
57
+ rescue ::StandardError, ::ScriptError
58
+ false
43
59
  end
44
60
 
45
61
  # Evaluates `code` inside the shared box and returns the result across the box boundary. The caller MUST
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effect_attribution"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ # One entry of a plugin's `effect_ancestry:` — an ancestry edge the plugin's own gem introduces and
8
+ # the project's source never writes down (ADR-103 WD17; [#465](https://github.com/rigortype/rigor/issues/465)).
9
+ #
10
+ # `Effects::PluginFacts` walks the project's own `class … <` and `include` lines and nothing else,
11
+ # deliberately: reading the RBS ancestor chain instead would make a row's reach a function of whether
12
+ # anyone happened to run `rbs prototype`. The cost is that a chain which leaves project source never
13
+ # comes back. `class UserMailer < Devise::Mailer` is the measured case — `Devise::Mailer <
14
+ # ActionMailer::Base` is a line in the devise gem, so `rigor-actionmailer`'s rows stop one step short
15
+ # of every mailer a Devise application writes, and the five `Auth::*Controller < Devise::*Controller`
16
+ # subclasses beside it are entry points.
17
+ #
18
+ # A plugin that models a gem knows that gem's own inheritance. Declaring it here keeps the rule that
19
+ # Rigor reads *declarations* rather than RBS: the declaration simply comes from the plugin instead of
20
+ # from project source.
21
+ #
22
+ # ## What a claim may say
23
+ #
24
+ # `parent:` need only be a **true ancestor**, not the immediate superclass. The sole use of the
25
+ # ancestry is to make a row reachable, and no plugin row is ever keyed on a project class, so
26
+ # skipping intermediate links loses nothing — while insisting on the immediate parent would force a
27
+ # claim that is sometimes false: `Devise::SessionsController`'s real parent is `DeviseController`,
28
+ # whose own parent is `Devise.parent_controller` and therefore configurable per project. A claim that
29
+ # skips links MUST say so in its `why:`.
30
+ #
31
+ # ## Who may make one
32
+ #
33
+ # Bundled plugins only ({Rigor::Plugin::FirstParty}), enforced in `PluginFacts`. An ancestry claim
34
+ # carries no labels of its own, so it looks harmless — but it makes *other* plugins' rows reachable,
35
+ # and a third-party plugin asserting `Foo < ActiveRecord::Base` would pull rigor-activerecord's
36
+ # first-party discharging rows onto `Foo`. The `effect_root:` demotion and the `discharge:` grant
37
+ # both answer their own version of that question the same way.
38
+ class EffectAncestry
39
+ attr_reader :child, :parent, :why
40
+
41
+ def initialize(child:, parent:, why:)
42
+ @child = validate_class_name!(child, "child")
43
+ @parent = validate_class_name!(parent, "parent")
44
+ @why = validate_why!(why)
45
+ freeze
46
+ end
47
+
48
+ def to_h
49
+ { "child" => @child, "parent" => @parent }
50
+ end
51
+
52
+ def ==(other)
53
+ other.is_a?(EffectAncestry) && to_h == other.to_h
54
+ end
55
+ alias eql? ==
56
+
57
+ def hash
58
+ to_h.hash
59
+ end
60
+
61
+ private
62
+
63
+ def validate_class_name!(value, role)
64
+ name = value.to_s
65
+ unless EffectAttribution::CLASS_NAME.match?(name)
66
+ raise ArgumentError, "effect ancestry #{role} must be a class name, got #{value.inspect}"
67
+ end
68
+
69
+ name.dup.freeze
70
+ end
71
+
72
+ def validate_why!(why)
73
+ value = why.to_s
74
+ raise ArgumentError, "effect ancestry #{@child} < #{@parent} needs a `why:` justification" if value.empty?
75
+
76
+ value.dup.freeze
77
+ end
78
+ end
79
+ end
80
+ end