rigortype 0.3.4 → 0.3.6

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
  6. data/docs/handbook/02-everyday-types.md +1 -1
  7. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  8. data/docs/handbook/08-understanding-errors.md +1 -1
  9. data/docs/handbook/09-plugins.md +2 -2
  10. data/docs/handbook/10-sorbet.md +1 -1
  11. data/docs/handbook/README.md +2 -2
  12. data/docs/handbook/appendix-go.md +1 -1
  13. data/docs/handbook/appendix-java-csharp.md +2 -2
  14. data/docs/handbook/appendix-mypy.md +1 -1
  15. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  16. data/docs/handbook/appendix-rust.md +1 -1
  17. data/docs/handbook/appendix-type-theory.md +2 -2
  18. data/docs/handbook/appendix-typescript.md +4 -4
  19. data/docs/manual/02-cli-reference.md +73 -22
  20. data/docs/manual/03-configuration.md +19 -2
  21. data/docs/manual/04-diagnostics.md +15 -2
  22. data/docs/manual/08-skills.md +1 -1
  23. data/docs/manual/11-ci.md +38 -1
  24. data/docs/manual/12-caching.md +40 -1
  25. data/docs/manual/15-type-protection-coverage.md +1 -1
  26. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  27. data/docs/manual/18-removing-dead-code.md +13 -4
  28. data/docs/manual/19-effect-labels.md +678 -0
  29. data/docs/manual/README.md +7 -2
  30. data/docs/manual/ci-templates/README.md +9 -0
  31. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  32. data/docs/manual/plugins/rigor-activejob.md +3 -3
  33. data/docs/manual/plugins/rigor-activerecord.md +4 -4
  34. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  35. data/docs/manual/plugins/rigor-rspec.md +6 -2
  36. data/lib/rigor/analysis/diagnostic.rb +17 -6
  37. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  38. data/lib/rigor/analysis/reachability/graph.rb +30 -11
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  40. data/lib/rigor/analysis/reachability/scan.rb +20 -4
  41. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  42. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  43. data/lib/rigor/analysis/run_cache_key.rb +12 -0
  44. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  45. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
  48. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  49. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  50. data/lib/rigor/analysis/runner.rb +163 -31
  51. data/lib/rigor/analysis/worker_session.rb +3 -1
  52. data/lib/rigor/cache/file_digest.rb +20 -2
  53. data/lib/rigor/cli/check_command.rb +67 -48
  54. data/lib/rigor/cli/coverage_command.rb +5 -6
  55. data/lib/rigor/cli/doc_links.rb +100 -0
  56. data/lib/rigor/cli/docs_command.rb +32 -2
  57. data/lib/rigor/cli/effects_command.rb +149 -10
  58. data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
  59. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  60. data/lib/rigor/cli/effects_renderer.rb +41 -3
  61. data/lib/rigor/cli/effects_report.rb +116 -6
  62. data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
  63. data/lib/rigor/cli/unused_command.rb +65 -16
  64. data/lib/rigor/cli.rb +33 -9
  65. data/lib/rigor/configuration.rb +37 -25
  66. data/lib/rigor/configuration_error.rb +20 -0
  67. data/lib/rigor/effects/collector.rb +38 -1
  68. data/lib/rigor/effects/definition_lines.rb +100 -0
  69. data/lib/rigor/effects/entry_points.rb +47 -1
  70. data/lib/rigor/effects/envelope_check.rb +18 -1
  71. data/lib/rigor/effects/file_collection.rb +18 -4
  72. data/lib/rigor/effects/framework_units.rb +68 -13
  73. data/lib/rigor/effects/inline_anchor.rb +134 -0
  74. data/lib/rigor/effects/liskov_check.rb +17 -8
  75. data/lib/rigor/effects/plugin_facts.rb +62 -13
  76. data/lib/rigor/effects/propagator.rb +79 -19
  77. data/lib/rigor/effects/registry.rb +10 -3
  78. data/lib/rigor/effects/scanner.rb +21 -9
  79. data/lib/rigor/effects/signature_sources.rb +29 -2
  80. data/lib/rigor/effects/snapshot.rb +74 -26
  81. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  82. data/lib/rigor/effects/taint_cause.rb +1 -0
  83. data/lib/rigor/effects/unit_scan.rb +87 -12
  84. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  85. data/lib/rigor/plugin/base.rb +4 -0
  86. data/lib/rigor/plugin/box.rb +18 -2
  87. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  88. data/lib/rigor/plugin/manifest.rb +34 -10
  89. data/lib/rigor/plugin/registry.rb +10 -3
  90. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  91. data/lib/rigor/rbs_extended.rb +22 -2
  92. data/lib/rigor/version.rb +1 -1
  93. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  94. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  95. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  96. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  97. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  98. data/skills/rigor-ci-setup/SKILL.md +2 -2
  99. data/skills/rigor-editor-setup/SKILL.md +2 -2
  100. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  101. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  102. data/skills/rigor-plugin-review/SKILL.md +3 -3
  103. metadata +10 -1
@@ -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
@@ -21,10 +21,21 @@ module Rigor
21
21
  DEFAULT_ROOTS = ["sig"].freeze
22
22
 
23
23
  # A cheap text pre-filter for "does this source carry an effect annotation at all". It matches
24
- # the two spellings the envelope reader honours and nothing else, so a signature tree with no
24
+ # the two payloads the envelope reader honours and nothing else, so a signature tree with no
25
25
  # effect annotation is answered by one regex per file and never parsed. It is a ROUTING test,
26
26
  # not the grammar — `RbsExtended.parse_effect_annotation` is still what decides meaning.
27
- ANNOTATION_HINT = /%a\{\s*(?:pure\s*\}|rigor:v1:effect\b)/
27
+ #
28
+ # RBS accepts five bracket pairs for an annotation, and the reader sees only the text inside
29
+ # them, so the hint must accept all five too: routing on `%a{` alone made a `%a(pure)` project
30
+ # invisible to the run-cache probe, which then served the fast path while a bound existed —
31
+ # the silent-lane shape the #428 family is about. Over-matching (a mismatched closer) is safe:
32
+ # the cost is one declined fast path or one parsed file, never a missed bound.
33
+ ANNOTATION_BRACKETS = { "{" => "}", "(" => ")", "[" => "]", "|" => "|", "<" => ">" }.freeze
34
+ ANNOTATION_HINT = Regexp.union(
35
+ ANNOTATION_BRACKETS.map do |opener, closer|
36
+ /%a#{Regexp.escape(opener)}\s*(?:pure\s*#{Regexp.escape(closer)}|rigor:v1:effect\b)/
37
+ end
38
+ )
28
39
 
29
40
  # A `virtual:<plugin-id>:<source path>` buffer is rbs-inline's (or a plugin's) synthesized RBS for
30
41
  # a Ruby file the author actually wrote in. Naming that file is what a reader can act on, so the
@@ -58,6 +69,22 @@ module Rigor
58
69
  signature_paths.nil? || signature_paths.empty? ? DEFAULT_ROOTS : signature_paths
59
70
  end
60
71
 
72
+ # The FIRST annotated virtual entry, as a `collect`-shaped one-element array (or an empty one).
73
+ #
74
+ # #441 — the analysis paths use this to hand the annotations-unchecked residual the inline stratum
75
+ # of a run that resolved an environment but never stored it. Reduced to one entry on purpose: the
76
+ # residual reports one annotation per run, so the carrier a run holds past the environment's own
77
+ # lifetime is a single synthesized buffer rather than the project's whole virtual tree.
78
+ #
79
+ # @param virtual_rbs [Array<Array(String, String)>, nil]
80
+ # @return [Array<Array(String, String)>] zero or one pair.
81
+ def annotated_carrier(virtual_rbs)
82
+ Array(virtual_rbs).each do |name, content|
83
+ return [[name.to_s, content.to_s]] if ANNOTATION_HINT.match?(content)
84
+ end
85
+ [].freeze
86
+ end
87
+
61
88
  # The first `[name, content, line]` carrying an effect annotation, or nil. Line numbers are
62
89
  # 1-based and count into `content`, which for a `virtual:` buffer is the SYNTHESIZED text — the
63
90
  # caller re-anchors it against the Ruby file when that matters.
@@ -38,7 +38,10 @@ module Rigor
38
38
  class Snapshot
39
39
  # Bumped when the file's shape changes in a way an older reader would misread. A bump makes every
40
40
  # existing file a regeneration event rather than a silent reinterpretation.
41
- SCHEMA = 1
41
+ # 2 — `unresolved:` became a count (#434). A schema-1 file still loads, and its only reported
42
+ # difference is this field, so an existing project sees one regeneration line rather than a
43
+ # reinterpretation.
44
+ SCHEMA = 2
42
45
 
43
46
  HEADER = "# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff."
44
47
 
@@ -47,8 +50,16 @@ module Rigor
47
50
 
48
51
  # One row of either table.
49
52
  #
50
- # `effects` and `declared` are sorted label arrays; `unresolved` is the sorted list of taint-cause
51
- # renderings behind a false `exhaustive`, and is empty when the row is exhaustive.
53
+ # `effects` and `declared` are sorted label arrays; `unresolved` is HOW MANY taint causes sit behind
54
+ # a false `exhaustive`, and is zero when the row is exhaustive.
55
+ #
56
+ # A count rather than the causes themselves (#434). On redmine the cause renderings were 144,771 of
57
+ # the file's 293,276 bytes, on lines up to 821 characters — simultaneously the half a reviewer
58
+ # cannot read and the half that churns when an unrelated call moves. This file already makes that
59
+ # argument about origins ("a record keyed by them would churn on every refactor"); the causes are
60
+ # the same class of thing and were the one place it was not applied. What a reviewer needs from the
61
+ # record is the stable fact that the row is not exhaustive and by how much, which is what a count
62
+ # is; `rigor effects explain` names the causes on demand, against the live table.
52
63
  class Entry < Data.define(:key, :effects, :declared, :exhaustive, :unresolved)
53
64
  def exhaustive?
54
65
  exhaustive
@@ -61,7 +72,7 @@ module Rigor
61
72
  row = { "effects" => effects }
62
73
  row["declared"] = declared unless declared.empty?
63
74
  row["exhaustive"] = false unless exhaustive
64
- row["unresolved"] = unresolved unless unresolved.empty?
75
+ row["unresolved"] = unresolved unless unresolved.zero?
65
76
  row
66
77
  end
67
78
 
@@ -73,10 +84,23 @@ module Rigor
73
84
  effects: string_list(row["effects"], key, "effects"),
74
85
  declared: string_list(row["declared"], key, "declared"),
75
86
  exhaustive: row.fetch("exhaustive", true) != false,
76
- unresolved: string_list(row["unresolved"], key, "unresolved")
87
+ unresolved: unresolved_count(row["unresolved"], key)
77
88
  )
78
89
  end
79
90
 
91
+ # Tolerant of the schema-1 spelling, which was the list itself: the field's whole point now is a
92
+ # stable number, and a schema-1 file must LOAD so the header's own mismatch can be reported as
93
+ # the one regeneration event it is. Refusing to parse it would turn a migration into an error.
94
+ def self.unresolved_count(value, key)
95
+ case value
96
+ when nil then 0
97
+ when Integer then value
98
+ when Array then value.length
99
+ else raise ParseError, "#{key.inspect} unresolved: expected a count, got #{value.class}"
100
+ end
101
+ end
102
+ private_class_method :unresolved_count
103
+
80
104
  def self.string_list(value, key, field)
81
105
  return [].freeze if value.nil?
82
106
  raise ParseError, "#{key.inspect} #{field}: expected a list, got #{value.class}" unless value.is_a?(Array)
@@ -164,11 +188,7 @@ module Rigor
164
188
  entries.flat_map do |entry|
165
189
  next [entry] if EntryPoints.glob?(entry)
166
190
 
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)")
191
+ EntryPoints.resolve!(entry)
172
192
  end.uniq.sort.freeze
173
193
  end
174
194
 
@@ -189,6 +209,29 @@ module Rigor
189
209
  parse(File.read(path, encoding: "UTF-8"))
190
210
  end
191
211
 
212
+ # A path as a reader sees it: project-relative when it is under the root, absolute when it is not.
213
+ #
214
+ # Public for the same reason {.render_causes} is — `reach:`'s entry-point globs and a drift row's
215
+ # source suffix (#435) must agree about what a project-relative path is, and a report that leaked
216
+ # `/private/tmp/nix-shell.../loud.rb` into a reviewer's terminal would be answering with this
217
+ # machine's layout rather than with the project's.
218
+ def relativize(path, project_root)
219
+ absolute = File.absolute_path(path.to_s)
220
+ root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
221
+ absolute.start_with?(root) ? absolute[root.length..] : absolute
222
+ end
223
+
224
+ # The taint causes as a reviewer reads them, from the closed enum of
225
+ # `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`.
226
+ #
227
+ # Public because two surfaces render the same causes and must not disagree about what one IS:
228
+ # a row's `unresolved:` count is the length of this list, and `rigor effects explain` prints the
229
+ # list itself for an `exhaustive → not` drift row (#434 / #435). One implementation, as WD3 asks.
230
+ def render_causes(causes)
231
+ causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
232
+ .uniq.sort.freeze
233
+ end
234
+
192
235
  private
193
236
 
194
237
  def parse_header(data)
@@ -245,12 +288,6 @@ module Rigor
245
288
  end
246
289
  end
247
290
 
248
- def relativize(path, project_root)
249
- absolute = File.absolute_path(path.to_s)
250
- root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
251
- absolute.start_with?(root) ? absolute[root.length..] : absolute
252
- end
253
-
254
291
  # Each table takes the lanes at ITS own reading: `methods:` records the direct summary, so its
255
292
  # `declared:` is what this method's own body claims, and `reach:` records the transitive one, so
256
293
  # its `declared:` is the fixpoint's — a controller reaching an attributed gem call through two
@@ -265,19 +302,10 @@ module Rigor
265
302
  effects: proven.to_a,
266
303
  declared: declared.excluding_subsumed_by(proven).to_a,
267
304
  exhaustive: exhaustive,
268
- unresolved: exhaustive ? [].freeze : render_causes(causes)
305
+ unresolved: exhaustive ? 0 : render_causes(causes).length
269
306
  )
270
307
  end
271
308
 
272
- # `unresolved:` says why the row is not exhaustive, from the closed taint-cause enum of
273
- # `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`. The
274
- # design note sketched this field as "call names"; the collector keeps causes rather than names,
275
- # and for the causes that have one the detail already IS the call name.
276
- def render_causes(causes)
277
- causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
278
- .uniq.sort.freeze
279
- end
280
-
281
309
  # A synthesised default summary — an `attr_*` writer's `mutate.self`, and the `Struct` / `Data`
282
310
  # accessors that join it when discovery synthesises them — carries no information a reviewer acts
283
311
  # on: it restates the `attr_accessor` line. Recognised by its origins being exactly the synthesised
@@ -335,6 +363,25 @@ module Rigor
335
363
  # Renders the file. Hand-rolled rather than `YAML.dump`ed so the leading comment, the key order and
336
364
  # the flow sequences are ours: two runs over one tree must be byte-identical, and a snapshot written
337
365
  # by a pooled run must equal the sequential one bit for bit.
366
+ # The hint goes in the artefact, not only in the run's output (#436): `reach:` is the half of the
367
+ # snapshot that answers what an entry point *causes*, and a project that never configured one
368
+ # commits the less interesting half with no way to know it. The run also names the presets its own
369
+ # plugin set registered, which this file cannot — a committed artefact must not carry a list that
370
+ # goes stale when the plugin list changes.
371
+ EMPTY_REACH_NOTE = [
372
+ "",
373
+ "# `reach:` is empty. It records the TRANSITIVE footprint at your entry points — what a",
374
+ "# controller action or a job causes, rather than what its own body does — and it is the half of",
375
+ "# this file most worth reviewing. Fill it in by naming entry points in `.rigor.yml`:",
376
+ "#",
377
+ "# effects:",
378
+ "# snapshot:",
379
+ "# reach: [rails] # a preset a plugin registered, or a project-relative file glob",
380
+ "#",
381
+ "# `rigor effects update` prints the presets this project's plugins actually register."
382
+ ].freeze
383
+ private_constant :EMPTY_REACH_NOTE
384
+
338
385
  def to_yaml
339
386
  lines = [HEADER]
340
387
  lines << "schema: #{@header.fetch('schema')}"
@@ -342,6 +389,7 @@ module Rigor
342
389
  lines << "vocabulary: #{@header.fetch('vocabulary')}"
343
390
  lines << "config_digest: #{scalar(@header.fetch('config_digest'))}"
344
391
  render_table(lines, "methods", @methods)
392
+ lines.concat(EMPTY_REACH_NOTE) if @reach.empty?
345
393
  render_table(lines, "reach", @reach)
346
394
  "#{lines.join("\n")}\n"
347
395
  end
@@ -107,6 +107,7 @@ module Rigor
107
107
  @undischarged = undischarged
108
108
  @added_symbols = 0
109
109
  @removed_symbols = 0
110
+ @suppressed = 0
110
111
  @events = build_events.freeze
111
112
  freeze
112
113
  end
@@ -123,9 +124,16 @@ module Rigor
123
124
  end
124
125
 
125
126
  # Renames are a removal plus an addition and are never reported as a lost effect; the footer is
126
- # where the reviewer sees that the two counts balance.
127
+ # where the reviewer sees that the two counts balance. `suppressed` is the per-symbol events a
128
+ # regeneration event withheld — zero on an ordinary comparison.
127
129
  def footer
128
- { added_symbols: @added_symbols, removed_symbols: @removed_symbols }
130
+ { added_symbols: @added_symbols, removed_symbols: @removed_symbols, suppressed: @suppressed }
131
+ end
132
+
133
+ # Whether the two sides were computed under different rules (#434). The per-symbol comparison is
134
+ # then meaningless rather than merely noisy, which is why {#build_events} withholds it.
135
+ def regeneration?
136
+ @events.any? { |event| event.category == REGENERATION }
129
137
  end
130
138
 
131
139
  def events_for(table)
@@ -145,10 +153,25 @@ module Rigor
145
153
  ADDITIVE_CATEGORIES.include?(event.category)
146
154
  end
147
155
 
156
+ # A regeneration event withholds the per-symbol comparison rather than printing it (#434).
157
+ #
158
+ # The header says the record was written under different rules, so the two sides are not
159
+ # comparable — a claim this class already makes in its own documentation and then contradicted by
160
+ # emitting every row anyway. On redmine a moved `config_digest:` produced one regeneration line
161
+ # followed by 482 `-symbol` lines, none of which was a review signal: they say the recorded set was
162
+ # computed differently, which the header already said once.
163
+ #
164
+ # The table comparison still RUNS, because its per-symbol counters are what tell the reader the
165
+ # scale of what is withheld. Only the events are dropped.
148
166
  def build_events
149
167
  return [missing_snapshot_event] if @recorded.nil?
150
168
 
151
- header_events + TABLES.flat_map { |table| table_events(table) }
169
+ header = header_events
170
+ table = TABLES.flat_map { |name| table_events(name) }
171
+ return table if header.empty?
172
+
173
+ @suppressed = table.length
174
+ header
152
175
  end
153
176
 
154
177
  def missing_snapshot_event
@@ -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)
@@ -65,6 +65,13 @@ module Rigor
65
65
  registries = collect_trait_registries(plugin_registry)
66
66
  nested_templates = collect_nested_class_templates(plugin_registry)
67
67
  return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
68
+ # Tier B alone cannot emit without an environment: every trait entry — the direct route and the
69
+ # concern-re-targeted one — funnels through `module_instance_method_names`, whose first line
70
+ # answers `[]` for a nil environment. The production pre-pass passes `environment: nil` (#476),
71
+ # so a project whose only contributing plugin registers trait registries (rigor-devise on a
72
+ # Rails app) would otherwise pay a whole-project parse to build a provably empty index. This
73
+ # gate mirrors that nil guard and MUST be removed in the change that threads a real environment.
74
+ return SyntheticMethodIndex::EMPTY if environment.nil? && templates.empty? && nested_templates.empty?
68
75
 
69
76
  asts = parse_paths(paths, buffer: buffer)
70
77
  hierarchy = build_hierarchy(asts)
@@ -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