rigortype 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +6 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  33. data/lib/rigor/analysis/incremental_session.rb +140 -6
  34. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  36. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  37. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  38. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  39. data/lib/rigor/analysis/rule_catalog.rb +158 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  41. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  42. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  43. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  44. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  45. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  46. data/lib/rigor/analysis/runner.rb +420 -10
  47. data/lib/rigor/analysis/worker_session.rb +58 -2
  48. data/lib/rigor/bleeding_edge.rb +22 -0
  49. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  50. data/lib/rigor/cli/check_command.rb +13 -1
  51. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  52. data/lib/rigor/cli/effects_command.rb +131 -0
  53. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  54. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  55. data/lib/rigor/cli/effects_renderer.rb +54 -0
  56. data/lib/rigor/cli/effects_report.rb +53 -0
  57. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  58. data/lib/rigor/cli/unused_command.rb +288 -0
  59. data/lib/rigor/cli.rb +16 -0
  60. data/lib/rigor/configuration/severity_profile.rb +23 -0
  61. data/lib/rigor/configuration.rb +296 -14
  62. data/lib/rigor/effects/attribution.rb +76 -0
  63. data/lib/rigor/effects/catalog.rb +275 -0
  64. data/lib/rigor/effects/collector.rb +182 -0
  65. data/lib/rigor/effects/config_envelopes.rb +185 -0
  66. data/lib/rigor/effects/discharge.rb +69 -0
  67. data/lib/rigor/effects/effect_table.rb +92 -0
  68. data/lib/rigor/effects/entry_points.rb +93 -0
  69. data/lib/rigor/effects/envelope.rb +86 -0
  70. data/lib/rigor/effects/envelope_check.rb +172 -0
  71. data/lib/rigor/effects/envelope_index.rb +157 -0
  72. data/lib/rigor/effects/file_collection.rb +160 -0
  73. data/lib/rigor/effects/framework_units.rb +167 -0
  74. data/lib/rigor/effects/identity.rb +104 -0
  75. data/lib/rigor/effects/label.rb +77 -0
  76. data/lib/rigor/effects/label_intent.rb +73 -0
  77. data/lib/rigor/effects/label_set.rb +136 -0
  78. data/lib/rigor/effects/liskov_check.rb +167 -0
  79. data/lib/rigor/effects/local_ownership.rb +132 -0
  80. data/lib/rigor/effects/method_key.rb +40 -0
  81. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  82. data/lib/rigor/effects/narrowing.rb +202 -0
  83. data/lib/rigor/effects/origin.rb +66 -0
  84. data/lib/rigor/effects/path_finder.rb +89 -0
  85. data/lib/rigor/effects/plugin_facts.rb +335 -0
  86. data/lib/rigor/effects/propagator.rb +275 -0
  87. data/lib/rigor/effects/registry.rb +193 -0
  88. data/lib/rigor/effects/scanner.rb +290 -0
  89. data/lib/rigor/effects/signature_sources.rb +74 -0
  90. data/lib/rigor/effects/snapshot.rb +380 -0
  91. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  92. data/lib/rigor/effects/summary.rb +154 -0
  93. data/lib/rigor/effects/taint_cause.rb +38 -0
  94. data/lib/rigor/effects/unit_scan.rb +572 -0
  95. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  96. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  97. data/lib/rigor/effects.rb +47 -0
  98. data/lib/rigor/environment/rbs_loader.rb +34 -0
  99. data/lib/rigor/environment.rb +8 -1
  100. data/lib/rigor/flow_contribution/element.rb +1 -0
  101. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  102. data/lib/rigor/flow_contribution/merger.rb +16 -1
  103. data/lib/rigor/flow_contribution.rb +20 -4
  104. data/lib/rigor/inference/expression_typer.rb +18 -4
  105. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  106. data/lib/rigor/inference/scope_indexer.rb +15 -3
  107. data/lib/rigor/plugin/base.rb +24 -0
  108. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  109. data/lib/rigor/plugin/effect_edge.rb +101 -0
  110. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  111. data/lib/rigor/plugin/first_party.rb +57 -0
  112. data/lib/rigor/plugin/isolation.rb +4 -1
  113. data/lib/rigor/plugin/manifest.rb +103 -3
  114. data/lib/rigor/plugin/registry.rb +48 -0
  115. data/lib/rigor/plugin.rb +1 -0
  116. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  117. data/lib/rigor/rbs_extended.rb +168 -0
  118. data/lib/rigor/reflection.rb +131 -30
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +1 -0
  121. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  122. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  123. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  124. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  125. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  126. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  127. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  128. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  132. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  134. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  135. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  136. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  137. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  139. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  140. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  141. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  142. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  143. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  144. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  145. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  146. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  147. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  148. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  149. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  150. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  151. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  152. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  153. data/sig/rigor/analysis/fact_store.rbs +1 -0
  154. data/sig/rigor/inference.rbs +2 -0
  155. data/sig/rigor/rbs_extended.rbs +6 -0
  156. data/sig/rigor.rbs +17 -1
  157. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  158. metadata +79 -1
@@ -18,7 +18,13 @@ require_relative "../rbs_extended/conformance_checker"
18
18
  require_relative "../reflection"
19
19
  require_relative "../type/combinator"
20
20
  require_relative "../inference/coverage_scanner"
21
+ require_relative "../effects/attribution"
22
+ require_relative "../effects/collector"
23
+ require_relative "../effects/envelope_index"
24
+ require_relative "../effects/identity"
25
+ require_relative "../effects/propagator"
21
26
  require_relative "../inference/parameter_inference_collector"
27
+ require_relative "../inference/pre_eval_constants"
22
28
  require_relative "../inference/scope_indexer"
23
29
  require_relative "../inference/synthetic_method_scanner"
24
30
  require_relative "../inference/project_patched_scanner"
@@ -40,6 +46,8 @@ require_relative "runner/run_snapshots"
40
46
  require_relative "runner/project_pre_passes"
41
47
  require_relative "runner/pool_coordinator"
42
48
  require_relative "runner/diagnostic_aggregator"
49
+ require_relative "runner/effect_envelope_pass"
50
+ require_relative "runner/effect_annotation_residual_pass"
43
51
  require_relative "runner/buffer_pool_dispatcher"
44
52
 
45
53
  module Rigor
@@ -68,6 +76,90 @@ module Rigor
68
76
  pooled.empty? ? @file_dependencies : @file_dependencies.merge(pooled)
69
77
  end
70
78
 
79
+ # ADR-103 — the run's propagated effect graph, or {Effects::EffectTable.empty} when collection did
80
+ # not run. Reconciled from the sequential and pooled halves exactly as {#file_dependencies} is, then
81
+ # closed by the post-pool fixpoint in {#assemble_run_diagnostics}.
82
+ #
83
+ # This is a **report surface, not a diagnostic one**: nothing here enters the diagnostic stream, and
84
+ # `rigor check`'s output is identical whether or not it was computed (ADR-102's line).
85
+ def effect_table
86
+ @effect_table || Effects::EffectTable.empty
87
+ end
88
+
89
+ # ADR-103 WD2 / WD6 / WD10 / #387 — the loaded plugins' effect contributions, compiled once per
90
+ # process. Memoised on first use rather than built in the constructor for two reasons: the plugin
91
+ # registry is adopted after construction (`apply_prebuilt` / the plugin-load pre-pass), and the
92
+ # project's as-written superclass table — which is what makes an `ActiveRecord::Base` row reach
93
+ # `User.find` — is filled by the cross-file discovery pre-pass, which on the sequential path runs
94
+ # AFTER the first file has already asked for these facts.
95
+ #
96
+ # So the memo is keyed on the ancestry table's identity rather than being unconditional, exactly as
97
+ # {#effect_envelope_index}'s is keyed on the environment's. An unconditional `||=` pinned the whole
98
+ # run to the empty table the first file saw, and the measured symptom was the Rails layer's central
99
+ # claim silently failing: `Issue.find` on a `Issue < ApplicationRecord < ActiveRecord::Base` found no
100
+ # row on a sequential run, while a pooled one (whose workers are seeded with the finished table
101
+ # before they fork) worked.
102
+ #
103
+ # Public and declared here beside the other effect surfaces: `rigor effects` builds its snapshot off
104
+ # the run's own vocabulary and has to read the same compiled tables the collection window scanned
105
+ # under.
106
+ def effect_plugin_facts
107
+ table = @project_discovered_superclasses
108
+ return @effect_plugin_facts if @effect_plugin_facts && @effect_plugin_facts_ancestry.equal?(table)
109
+
110
+ @effect_plugin_facts_ancestry = table
111
+ @effect_plugin_facts = Effects::PluginFacts.build(@plugin_registry, superclasses: table)
112
+ end
113
+
114
+ # The merged per-file collections behind {#effect_table} — the *direct* summaries, before the graph
115
+ # closure. #381's snapshot records these, because a diff over direct summaries stays attributable to
116
+ # the pull request's own lines.
117
+ def effect_collection
118
+ Effects::FileCollection.merge_all(effect_collections)
119
+ end
120
+
121
+ # Issue #382 — the same collections keyed by the path that produced each, which is the form the two
122
+ # caches persist: `{ "lib/a.rb" => FileCollection, … }`. The merged {#effect_collection} cannot be
123
+ # persisted per file (merging is where the path is deliberately dropped), and per file is what an
124
+ # ADR-46 recheck needs — it re-collects the changed closure and serves the rest from the snapshot.
125
+ def effect_collections_by_path
126
+ pooled = @pool_coordinator.collected_effects
127
+ pooled.empty? ? @file_effects.dup : @file_effects.merge(pooled)
128
+ end
129
+
130
+ # Issue #382 — adopt persisted collections as this run's own and close the graph over them. The
131
+ # warm-hit half of the whole-run effects slot: the analysis did not run, so `#assemble_run_diagnostics`
132
+ # never reached {#close_effect_graph} and the fixpoint is run here instead. The fixpoint is ALWAYS
133
+ # re-run rather than persisted — it is the cheap half, and a stored table would have to be invalidated
134
+ # by every input a summary has.
135
+ def adopt_effect_collections(collections)
136
+ @file_effects = collections
137
+ close_effect_graph
138
+ end
139
+
140
+ # Issue #382 — whether this run's effect collections came from the whole-run effects slot rather than
141
+ # from a fresh collection pass. Read by the specs and by nothing in the engine; `#effect_table` reads
142
+ # the same either way, which is the property the slot exists to provide.
143
+ def effects_served_from_cache?
144
+ @effects_served_from_cache
145
+ end
146
+
147
+ # Which file each effect unit was defined in — `{ "Class#m" => [path, …] }`, sorted, one entry per
148
+ # file that contributes a `def` to the key (a reopening spans several).
149
+ #
150
+ # The merged {#effect_collection} cannot answer this: merging drops the per-file path, deliberately,
151
+ # because a summary is line- and file-free by design. The snapshot's `reach:` table needs it anyway —
152
+ # its entry points are named by *file* globs (`effects.snapshot.reach:`, the `unused --entry-point`
153
+ # syntax), so the key has to be traced back to the file it was written in.
154
+ def effect_sources
155
+ effect_collections.each_with_object({}) do |collection, out|
156
+ path = collection.path
157
+ next if path.nil?
158
+
159
+ collection.summaries.each_key { |key| (out[key] ||= []) << path }
160
+ end
161
+ end
162
+
71
163
  # @param configuration [Rigor::Configuration]
72
164
  # @param explain [Boolean] surface fail-soft fallback events as `:info` diagnostics.
73
165
  # @param cache_store [Rigor::Cache::Store, nil] the persistent cache the runner exposes to producers
@@ -102,13 +194,16 @@ module Rigor
102
194
  # discovery tables": {Protection::DiagnosticOracle} threads the table set Tier 2's site filter already
103
195
  # judges anchors against, so a site admitted because a sibling-file class resolved is also a site the
104
196
  # oracle can kill at. nil (the default) leaves the prebuilt/LSP contract byte-identical.
197
+ # @param no_tolerated_effects [Boolean] ADR-103 WD1 / #385 — `rigor check --no-tolerated-effects`.
198
+ # Judges effect envelopes as if `effects.tolerated:` were empty. A judgment-time switch only: the
199
+ # run, its collection and its cache identity are unchanged.
105
200
  def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
106
201
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
107
202
  plugin_requirer: nil, workers: 0, collect_stats: true,
108
203
  buffer: nil, prebuilt: nil, environment: nil,
109
204
  record_dependencies: false, record_self_calls: false, analyze_only: nil,
110
205
  seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
111
- discovery_seed: nil)
206
+ discovery_seed: nil, no_tolerated_effects: false)
112
207
  @configuration = configuration
113
208
  @explain = explain
114
209
  @cache_store = enforce_read_only_cache(cache_store, buffer)
@@ -128,6 +223,30 @@ module Rigor
128
223
  # diagnostics are byte-identical.
129
224
  @record_self_calls = record_self_calls
130
225
  @unresolved_self_calls = {}
226
+ # ADR-103 WD13 — effect collection. Derived from the configuration, never from a flag: the
227
+ # `effects:` block (or an implicit one, which is how `rigor effects` opts in) is the ONLY switch.
228
+ # Observational — diagnostics are byte-identical whichever way it resolves.
229
+ @record_effects = configuration.effects_enabled?
230
+ # ADR-103 WD6 / #385 — the project's `effects.attribution:` table, built once and carried on the
231
+ # collection window so a fork-pool worker scans under the same claims the parent does.
232
+ @effect_attribution = Effects::Attribution.build(configuration.effects_attribution)
233
+ # ADR-103 WD6 / #386 — the call-site envelope index, built lazily off the run's environment
234
+ # (`#effect_envelope_index`) because the strata it reads include the built RBS one.
235
+ @effect_envelope_index = nil
236
+ @effect_envelope_index_env = nil
237
+ # #387 — the compiled plugin effect tables, memoised on the ancestry table they were built from
238
+ # (see `#effect_plugin_facts`).
239
+ @effect_plugin_facts = nil
240
+ @effect_plugin_facts_ancestry = nil
241
+ # ADR-103 WD1 invariant 3 / #385 — `--no-tolerated-effects`, the audit switch. It changes the
242
+ # JUDGMENT only: collection, the propagated table and the cache identity are all untouched, so an
243
+ # audit run and an ordinary one share a cache entry and differ solely in which lane the envelope
244
+ # check reads.
245
+ @no_tolerated_effects = no_tolerated_effects
246
+ @file_effects = {}
247
+ @effect_table = nil
248
+ @effects_served_from_cache = false
249
+ @run_dependency_descriptor = nil
131
250
  # Memoised activation decision for the `call.self-undefined-method` rule (nil = not yet computed).
132
251
  # See `self_undefined_rule_active?`.
133
252
  @self_undefined_rule_active = nil
@@ -189,6 +308,11 @@ module Rigor
189
308
  # scope (sequential + fork-worker) through `project_scope_seed_tables`. Empty by default, so the gate-off
190
309
  # run carries no table and is byte-identical.
191
310
  @project_param_inferred_types = {}.freeze
311
+ # Issue #352 / ADR-17 — the `pre_eval:` constant publication table, populated by
312
+ # `seed_pre_eval_constants` in `assemble_run_diagnostics` and seeded onto every per-file scope
313
+ # (sequential + fork-worker) through `project_scope_seed_tables`. Empty unless the project lists
314
+ # `pre_eval:` files that declare publishable constants, so a project without one is byte-identical.
315
+ @project_pre_eval_constants = {}.freeze
192
316
  # ADR-84 WD2 — per-run identity token for the user-method return memo's bucket (see
193
317
  # Scope::DiscoveryIndex#run_generation). Minted fresh in `run_analysis` so the memo never serves an
194
318
  # entry across a run boundary (LSP re-check, ADR-62 warm loop); nil until the first run so
@@ -232,6 +356,9 @@ module Rigor
232
356
  @snapshots.reset_for_run
233
357
  # Per-run reset of the deferred-discovery memo (see `#ensure_project_discovery`).
234
358
  @project_discovery_done = false
359
+ # Per-run reset of the environment the cacheable path resolves, reused by the envelope pass so a
360
+ # run never builds two.
361
+ @run_environment = nil
235
362
  # ADR-84 WD2 — roll the return-memo bucket: a fresh frozen token per run makes every per-file scope
236
363
  # of THIS run share one memo bucket while entries from any earlier run in this process (stale after
237
364
  # an edit) become unreachable.
@@ -249,6 +376,16 @@ module Rigor
249
376
  ensure_project_discovery(expansion) if force_eager_discovery?
250
377
 
251
378
  diagnostics = compute_run_diagnostics(expansion)
379
+ # ADR-103 WD12 / #383 — `effect.envelope-exceeded`, recomputed every run from the (possibly
380
+ # cached) effect table and never stored. It sits OUTSIDE `compute_run_diagnostics` on purpose:
381
+ # the `effects:` block is absent from the diagnostics cache identity, so a finding written into
382
+ # that entry would outlive the configuration that produced it. See {EffectEnvelopePass}.
383
+ diagnostics += effect_envelope_diagnostics(expansion)
384
+ # ADR-103 WD13 commitment 1 / #384 — the mirror image: a project that wrote effect annotations
385
+ # and NO `effects:` block. Same placement rationale (the block is absent from the diagnostics
386
+ # cache identity, so a residual stored there would outlive the edit that answers it), and the
387
+ # opposite gate, so exactly one of the two ever runs.
388
+ diagnostics += effect_annotation_residual_diagnostics
252
389
 
253
390
  Result.new(
254
391
  diagnostics: @diagnostic_aggregator.apply_severity_profile(diagnostics),
@@ -493,19 +630,27 @@ module Rigor
493
630
  else
494
631
  Cache::Descriptor.new
495
632
  end
633
+ @run_environment = environment
496
634
  key_descriptor = run_key_descriptor(expansion, rbs_descriptor)
497
635
  return assemble_run_diagnostics(expansion, environment: environment) if key_descriptor.nil?
498
636
 
637
+ # ADR-103 WD13 / #382 — the effects slot is consulted FIRST, because it is the one that can force
638
+ # the analysis: a missing or differently-identified sidecar is a miss for effects consumers only,
639
+ # and the only way to re-collect is to re-analyze. When it hits (or collection is off) this returns
640
+ # nil and the diagnostics slot decides on its own, exactly as it did before effects existed.
641
+ analysis = serve_effect_collections(expansion, environment, key_descriptor, rbs_descriptor)
499
642
  computed = false
500
643
  diagnostics = @cache_store.fetch_or_validate(
501
644
  producer_id: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID, key_descriptor: key_descriptor,
502
645
  generation_cap: RunCacheKey::GENERATION_CAP
503
646
  ) do
504
647
  computed = true
505
- diags = assemble_run_diagnostics(expansion, environment: environment)
648
+ diags = analysis || assemble_run_diagnostics(expansion, environment: environment)
506
649
  [diags, run_dependency_descriptor(expansion, rbs_descriptor)]
507
650
  end
508
- @run_served_from_cache = !computed
651
+ # An effects miss that ran the analysis is not a cache-served run even when the diagnostics slot
652
+ # then hit — the stats it would otherwise suppress were all gathered.
653
+ @run_served_from_cache = !computed && analysis.nil?
509
654
  diagnostics
510
655
  rescue StandardError
511
656
  # The result cache must never break a run. If anything in the cache path fails, fall back to a
@@ -514,6 +659,143 @@ module Rigor
514
659
  assemble_run_diagnostics(expansion)
515
660
  end
516
661
 
662
+ # ADR-103 WD13 / issue #382 — the whole-run **effects sidecar**, the second of "one cache, two
663
+ # identities, one extra slot". Keyed by {Effects::Identity.descriptor} — the diagnostics key
664
+ # descriptor plus the vocabulary version, the catalogue identity and the `effects:` digest — and
665
+ # validated against the same post-run dependency descriptor the diagnostics slot records, so exactly
666
+ # the file set that invalidates diagnostics invalidates summaries.
667
+ #
668
+ # Returns the diagnostics of the analysis it had to run (a miss — the run has to re-collect, and the
669
+ # only way to collect is to analyze), or nil when it did not have to run one (a hit, or collection
670
+ # off). The diagnostics slot is never written, read, or invalidated from here.
671
+ def serve_effect_collections(expansion, environment, key_descriptor, rbs_descriptor)
672
+ return nil unless @record_effects
673
+
674
+ descriptor = effects_key_descriptor(key_descriptor)
675
+ cached = descriptor && peek_effect_collections(descriptor)
676
+ if cached
677
+ adopt_effect_collections(cached)
678
+ @effects_served_from_cache = true
679
+ return nil
680
+ end
681
+
682
+ analysis = assemble_run_diagnostics(expansion, environment: environment)
683
+ store_effect_collections(descriptor, expansion, rbs_descriptor)
684
+ analysis
685
+ end
686
+
687
+ def effects_key_descriptor(key_descriptor)
688
+ Effects::Identity.descriptor(base: key_descriptor, configuration: @configuration,
689
+ plugin_facts: effect_plugin_facts)
690
+ rescue StandardError
691
+ nil
692
+ end
693
+
694
+ # The read half. A miss, a stale dependency, a corrupt entry and a stored value of the wrong shape
695
+ # are one answer — nil, "collect it again" — because none of them can be told apart from the outside
696
+ # and all of them have the same remedy.
697
+ def peek_effect_collections(descriptor)
698
+ cached = @cache_store.peek_validated(
699
+ producer_id: RunCacheKey::RUN_EFFECTS_PRODUCER_ID, key_descriptor: descriptor
700
+ )
701
+ cached.is_a?(Hash) ? cached : nil
702
+ rescue StandardError
703
+ nil
704
+ end
705
+
706
+ # The write half, run only after a miss, so the block never recomputes anything: it hands over the
707
+ # collections the analysis just produced, validated against the same post-run dependency descriptor
708
+ # the diagnostics slot records. Fail-soft — a collection that will not Marshal (which nothing in
709
+ # {Effects::FileCollection} should be, and the fork pool already proves per file) costs the next run
710
+ # its warm start and nothing else.
711
+ def store_effect_collections(descriptor, expansion, rbs_descriptor)
712
+ return if descriptor.nil?
713
+
714
+ collections = effect_collections_by_path
715
+ @cache_store.fetch_or_validate(
716
+ producer_id: RunCacheKey::RUN_EFFECTS_PRODUCER_ID, key_descriptor: descriptor,
717
+ generation_cap: RunCacheKey::EFFECTS_GENERATION_CAP
718
+ ) { [collections, run_dependency_descriptor(expansion, rbs_descriptor)] }
719
+ nil
720
+ rescue StandardError
721
+ nil
722
+ end
723
+ private :serve_effect_collections, :effects_key_descriptor,
724
+ :peek_effect_collections, :store_effect_collections
725
+
726
+ # ADR-103 WD8 / #383 — the envelope check. Nothing at all without an `effects:` block, and nothing
727
+ # under `effects.check: false`; with both, one walk of the project's own RBS for `%a{pure}` /
728
+ # `%a{rigor:v1:effect …}`, and only if that finds an envelope does anything else run (the discovery
729
+ # tables the `def` positions come from are forced from inside the pass, lazily, for that reason).
730
+ #
731
+ # The environment is the one the cacheable path already resolved when there is one, so a warm run
732
+ # reads the envelopes off the loader it built anyway rather than building a second.
733
+ def effect_envelope_diagnostics(expansion)
734
+ return [] unless @record_effects && @configuration.effects_check?
735
+
736
+ EffectEnvelopePass.new(
737
+ configuration: @configuration,
738
+ rbs_loader: envelope_rbs_loader(expansion),
739
+ effect_table: effect_table,
740
+ discovery: -> { envelope_discovery_tables(expansion) },
741
+ sources: @in_memory_sources,
742
+ unit_sources: effect_sources,
743
+ # ADR-103 WD1 / #386 — the nominal relation `effect.liskov-widened` reads, from the collector's
744
+ # own as-written superclass table, so the Liskov check and the closed-world proven lane resolve
745
+ # the same ancestry. Lazy: only a project that declared an envelope pays the merge.
746
+ ancestry: -> { effect_collection.superclasses },
747
+ apply_tolerated: !@no_tolerated_effects,
748
+ # #387 — the same compiled plugin tables the collection window scanned under, so an envelope may
749
+ # name a label a plugin opened and the unknown-label check agrees with the scan.
750
+ plugin_facts: effect_plugin_facts
751
+ ).diagnostics
752
+ end
753
+
754
+ # The residual takes the loader the run ALREADY resolved — never `envelope_rbs_loader`, which
755
+ # builds one on demand. This runs on the effects-off path, where an environment build is a cost
756
+ # the project did not ask for; the `signature_paths:` `.rbs` stratum is always read, and the
757
+ # rbs-inline stratum rides whatever the run happened to have.
758
+ def effect_annotation_residual_diagnostics
759
+ EffectAnnotationResidualPass.new(
760
+ configuration: @configuration, rbs_loader: @run_environment&.rbs_loader
761
+ ).diagnostics
762
+ end
763
+
764
+ def envelope_rbs_loader(expansion)
765
+ environment = @run_environment ||
766
+ @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
767
+ environment&.rbs_loader
768
+ rescue StandardError
769
+ nil
770
+ end
771
+
772
+ def envelope_discovery_tables(expansion)
773
+ ensure_project_discovery(expansion)
774
+ [@project_discovered_def_sources, @project_discovered_singleton_def_sources,
775
+ @project_discovered_class_sources]
776
+ end
777
+
778
+ private :effect_envelope_diagnostics, :effect_annotation_residual_diagnostics,
779
+ :envelope_rbs_loader, :envelope_discovery_tables
780
+
781
+ # ADR-103 WD13 — fail-soft at the run level too: a propagator that raises leaves the table empty and
782
+ # the run untouched. `Propagator.propagate` already swallows its own failures; this guards the merge.
783
+ def close_effect_graph
784
+ return unless @record_effects
785
+
786
+ @effect_table = Effects::Propagator.propagate(effect_collection, discharge: effect_discharge)
787
+ rescue StandardError
788
+ @effect_table = Effects::EffectTable.empty
789
+ end
790
+
791
+ # ADR-103 WD14 / #385 — the `effects.tolerated:` policy the propagator's second (undischarged) lane
792
+ # is computed under. Built from the configuration and never from the audit flag: BOTH lanes are in
793
+ # the table, and `--no-tolerated-effects` picks the other one at judgment time, so one fixpoint
794
+ # serves an audit run and an ordinary one alike.
795
+ def effect_discharge
796
+ @effect_discharge ||= Effects::Discharge.new(@configuration.effects_tolerated)
797
+ end
798
+
517
799
  def assemble_run_diagnostics(expansion, environment: nil)
518
800
  # Force the deferred cross-file discovery pre-pass on the analysis (miss) path. Memoised, so the
519
801
  # eager force in `#run` (recording / subset modes) makes this a no-op. A warm cache HIT never calls
@@ -526,6 +808,11 @@ module Rigor
526
808
  # assembles). Gate off → no-op, and `environment` is left untouched so its lazy build timing is
527
809
  # unchanged. Returns the resolved environment so the sequential dispatch reuses it (no double build).
528
810
  environment = seed_parameter_inference(expansion, environment)
811
+ # Issue #352 — the `pre_eval:` constant publication pre-pass. Same placement rationale as the line
812
+ # above: it runs on the parent BEFORE the pool split so every worker sees the same frozen table, and
813
+ # only on the analysis (miss) path. No `pre_eval:` entry → no-op, and `environment` passes through
814
+ # untouched so its lazy build timing is unchanged.
815
+ environment = seed_pre_eval_constants(expansion, environment)
529
816
  diagnostics = @diagnostic_aggregator.pre_file_diagnostics(expansion)
530
817
  # ADR-46 — record which project files this run actually analyzed (the `analyze_only` subset, or
531
818
  # all of them). The incremental orchestrator serves every analyzed-but-not-affected file from the
@@ -533,6 +820,11 @@ module Rigor
533
820
  targets = target_files(expansion)
534
821
  @analyzed_files = targets
535
822
  diagnostics += @pool_coordinator.analyze_files(targets, environment: environment)
823
+ # ADR-103 WD12 — the effect fixpoint, in the post-pool aggregation slot beside the conformance
824
+ # results. Graph-only over a finite lattice, so it is a plain worklist to a true fixpoint; it
825
+ # contributes NO diagnostics and its result leaves through `#effect_table`, never through the
826
+ # stream. Skipped entirely when collection did not run.
827
+ close_effect_graph
536
828
  diagnostics += @diagnostic_aggregator.rbs_quarantined_signature_diagnostics
537
829
  diagnostics += @diagnostic_aggregator.rbs_environment_build_failed_diagnostics
538
830
  diagnostics += @diagnostic_aggregator.rbs_synthesized_namespace_diagnostics
@@ -572,6 +864,34 @@ module Rigor
572
864
  environment
573
865
  end
574
866
 
867
+ # Issue #352 / ADR-17 — the `pre_eval:` CONSTANT publication pre-pass, the twin of the slice-2 patched-
868
+ # METHOD registry the eager pre-passes already build. Walks each listed file's constant writes with the
869
+ # per-file pre-pass ({Inference::ScopeIndexer.build_in_source_constants}) under a project-seeded scope,
870
+ # widens each result to its erased class, and populates `@project_pre_eval_constants` — which
871
+ # `project_scope_seed_tables` then seeds onto every per-file scope, so `TIMEOUT = 30` in a listed file
872
+ # reads as `Integer` instead of `Dynamic[top]` in its consumers. The widening + multi-file conflict rules
873
+ # live in {Inference::PreEvalConstants}.
874
+ #
875
+ # Runs here rather than in {ProjectPrePasses#run} because typing a constant's rvalue needs the RBS
876
+ # environment, which the eager pre-passes feed rather than consume. Fails soft — a collector error must
877
+ # never break a run, so the table stays empty and the run proceeds exactly as it does today.
878
+ def seed_pre_eval_constants(expansion, environment)
879
+ paths = @configuration.pre_eval.select { |path| File.file?(path) }
880
+ return environment if paths.empty?
881
+
882
+ environment ||= @pool_coordinator.resolve_sequential_environment(source_files: expansion.fetch(:files))
883
+ @project_pre_eval_constants = Inference::PreEvalConstants.collect(
884
+ paths: paths, target_ruby: @configuration.target_ruby, buffer: @buffer,
885
+ scope_builder: lambda { |path|
886
+ seed_project_scope(Scope.empty(environment: environment, source_path: path))
887
+ }
888
+ )
889
+ environment
890
+ rescue StandardError
891
+ @project_pre_eval_constants = {}.freeze
892
+ environment
893
+ end
894
+
575
895
  # A cache hit skipped the analysis, so the per-run stats (wall split, RBS-class counts, …) were never
576
896
  # gathered — report none rather than the stale snapshot defaults.
577
897
  def stats_for_run(wall_started_at:, expansion:)
@@ -592,6 +912,11 @@ module Rigor
592
912
  # share the full run's result key (`run_key_descriptor` keys on the whole expansion, not the
593
913
  # subset), so serving one as the other would manufacture a wrong result. Both instead use the
594
914
  # store for the RBS-env + plugin-producer tiers, where the incremental win actually lives.
915
+ # ADR-103 WD13 / issue #382 — a COLLECTING run is no longer excluded. It was, for the same reason a
916
+ # `record_dependencies` run is (a cache-served run collects nothing, so `#effect_table` would come
917
+ # back empty from a warm hit), and the sidecar slot is what lifts it: the collections ride their own
918
+ # entry under their own identity, so a warm run restores them and re-runs only the fixpoint. A run
919
+ # with collection OFF never reads or writes that slot and is byte-identical here.
595
920
  def run_result_cacheable?
596
921
  !@cache_store.nil? && !@cache_store.read_only? &&
597
922
  @buffer.nil? && @prebuilt.nil? && !pool_mode? &&
@@ -615,8 +940,16 @@ module Rigor
615
940
  # Files the run actually depended on, collected AFTER it ran: every analyzed file, every RBS `sig`
616
941
  # file (`rbs_descriptor.files`), and every file each plugin read (complete post-run, so reads made
617
942
  # mid-analysis are included). Re-digested on the next run by {Descriptor#fresh?}.
943
+ # Memoised because a collecting run asks twice — once to validate the effects sidecar it just wrote,
944
+ # once for the diagnostics entry — and this is a whole-project stat + digest walk. Both asks happen
945
+ # after the same analysis, so they are answering about the same world; a run with collection off asks
946
+ # once and the memo is inert.
618
947
  def run_dependency_descriptor(expansion, rbs_descriptor)
619
- entries = analyzed_file_entries(expansion) + rbs_descriptor.files
948
+ @run_dependency_descriptor ||= build_run_dependency_descriptor(expansion, rbs_descriptor)
949
+ end
950
+
951
+ def build_run_dependency_descriptor(expansion, rbs_descriptor)
952
+ entries = analyzed_file_entries(expansion) + pre_eval_file_entries + rbs_descriptor.files
620
953
  @plugin_registry.plugins.each do |plugin|
621
954
  # Read the boundary WITHOUT triggering its lazy `@io_boundary ||=` initializer: plugin instances
622
955
  # are frozen after the run, and a plugin that never built a boundary read no files through it,
@@ -636,6 +969,21 @@ module Rigor
636
969
  end
637
970
  end
638
971
 
972
+ # Issue #352 — a `pre_eval:` file is a real input to the run's diagnostics: its `def`s populate the
973
+ # ADR-17 patched-method registry and (since #352) its constants populate the project seed. ADR-17 WD5
974
+ # permits listing a file under `pre_eval:` and NOT under `paths:`, and such a file never appears in the
975
+ # expansion — so without this entry, editing one would leave the run-result cache serving diagnostics
976
+ # computed against the previous version. The common case (a `lib/core_ext/` file that is also under
977
+ # `paths:`) contributes a duplicate entry, which the descriptor's per-path validation absorbs.
978
+ def pre_eval_file_entries
979
+ @configuration.pre_eval.filter_map do |path|
980
+ physical = @buffer ? @buffer.resolve(path) : path
981
+ next unless File.file?(physical)
982
+
983
+ Cache::Descriptor::FileEntry.stat(path: physical, digest: Cache::FileDigest.hexdigest(physical))
984
+ end
985
+ end
986
+
639
987
  # Runs every project-wide pre-pass (`load_plugins` +
640
988
  # `plugin#prepare` + dependency-source builder +
641
989
  # synthetic-method scanner + project-patched scanner)
@@ -798,6 +1146,13 @@ module Rigor
798
1146
 
799
1147
  private
800
1148
 
1149
+ # The run's per-file effect collections in sorted path order — the sequential half merged with the
1150
+ # pool's, exactly as {#file_dependencies} reconciles its two halves. Sorted, because the fold below
1151
+ # it must not depend on pool-completion order.
1152
+ def effect_collections
1153
+ effect_collections_by_path.sort_by { |path, _| path.to_s }.map(&:last)
1154
+ end
1155
+
801
1156
  # Editor mode § "Scope choice — option A". Under `buffer:` non-nil the per-file analysis emits
802
1157
  # diagnostics ONLY for the buffer's logical path; the rest of `paths:` is consumed by the
803
1158
  # project-wide pre-passes (synthetic methods, project-patched methods, plugin facts) but contributes
@@ -1106,10 +1461,7 @@ module Rigor
1106
1461
  tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
1107
1462
  end
1108
1463
  tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
1109
- # ADR-67 WD6a — the call-site parameter-inference table rides the same seed so a pooled `WorkerSession`
1110
- # scope seeds inferred parameters identically to the sequential path. Empty (and absent) unless the
1111
- # `parameter_inference:` gate ran the pre-pass.
1112
- tables[:param_inferred_types] = @project_param_inferred_types unless @project_param_inferred_types.empty?
1464
+ seed_opt_in_pre_pass_tables(tables)
1113
1465
  seed_member_layout_tables(tables)
1114
1466
  # ADR-46 slice 1 — the class-declaration source map is read only by the ancestry accessors during
1115
1467
  # dependency recording, so seed it only when recording is on; a normal run never carries it.
@@ -1126,6 +1478,23 @@ module Rigor
1126
1478
  @discovery_seed ? @discovery_seed.dup : {}
1127
1479
  end
1128
1480
 
1481
+ # Seeds the two OPT-IN pre-pass tables — the ones an ordinary run leaves empty, so they are absent from
1482
+ # the seed entirely unless the project asked for them. Both ride this seed so a pooled `WorkerSession`
1483
+ # scope resolves identically to the sequential path (ADR-15 sequential equivalence).
1484
+ #
1485
+ # - ADR-67 WD6a `param_inferred_types`: the call-site parameter-inference table, populated only when the
1486
+ # `parameter_inference:` gate ran the pre-pass.
1487
+ # - Issue #352 `in_source_constants`: the `pre_eval:` constant publication, populated only when the
1488
+ # project lists `pre_eval:` files that declare publishable constants.
1489
+ #
1490
+ # Extracted to keep {#project_scope_seed_tables} under the complexity budget.
1491
+ def seed_opt_in_pre_pass_tables(tables)
1492
+ tables[:param_inferred_types] = @project_param_inferred_types unless @project_param_inferred_types.empty?
1493
+ return if @project_pre_eval_constants.empty?
1494
+
1495
+ tables[:in_source_constants] = @project_pre_eval_constants
1496
+ end
1497
+
1129
1498
  # ADR-46 — seed the instance + singleton `"path:line"` def-source tables (each only when non-empty).
1130
1499
  # Extracted to keep {#project_scope_seed_tables} under the complexity budget. The singleton table (slice 4
1131
1500
  # extension) rides the same seed so a pooled `WorkerSession` records singleton symbol edges identically.
@@ -1151,16 +1520,52 @@ module Rigor
1151
1520
  # `DependencyRecorder.active? == false`. Recording is observational, so diagnostics are byte-identical
1152
1521
  # either way.
1153
1522
  def analyze_file(path, environment)
1154
- return analyze_file_body(path, environment) unless @record_dependencies
1523
+ return analyze_with_effects(path, environment) unless @record_dependencies
1155
1524
 
1156
1525
  diagnostics = nil
1157
1526
  record = DependencyRecorder.record_for(path) do
1158
- diagnostics = analyze_file_body(path, environment)
1527
+ diagnostics = analyze_with_effects(path, environment)
1159
1528
  end
1160
1529
  @file_dependencies[path] = record
1161
1530
  diagnostics
1162
1531
  end
1163
1532
 
1533
+ # ADR-103 WD13 — the effect-collection window, in the same shape as the dependency one and nested
1534
+ # inside it so a recording incremental run collects both. Off by default: `effects_enabled?` false
1535
+ # calls the body directly, {Effects::Collector.active?} stays false, and the recorder's sites on the
1536
+ # dispatch path short-circuit on one integer read.
1537
+ def analyze_with_effects(path, environment)
1538
+ return analyze_file_body(path, environment) unless @record_effects
1539
+
1540
+ diagnostics = nil
1541
+ collection = Effects::Collector.collect_for(
1542
+ path, attribution: @effect_attribution, envelopes: effect_envelope_index(environment),
1543
+ plugin_facts: effect_plugin_facts
1544
+ ) do
1545
+ diagnostics = analyze_file_body(path, environment)
1546
+ end
1547
+ @file_effects[path] = collection
1548
+ diagnostics
1549
+ end
1550
+
1551
+ # ADR-103 WD6 / #386 — the envelopes a call site may import as a `≤` bound, built once per process
1552
+ # over the first environment the analysis resolves and reused for every later file. A worker builds
1553
+ # its own from the same configuration and the same signature content ({WorkerSession}), so the two
1554
+ # agree without a channel to keep in sync.
1555
+ #
1556
+ # Memoised on the environment's identity rather than unconditionally: a run that resolves its
1557
+ # environment lazily hands `nil` to the first files, and an index built from `nil` would silently
1558
+ # pin the whole run to the strata a loader-less build can read.
1559
+ def effect_envelope_index(environment)
1560
+ return @effect_envelope_index if @effect_envelope_index && @effect_envelope_index_env.equal?(environment)
1561
+
1562
+ @effect_envelope_index_env = environment
1563
+ @effect_envelope_index = Effects::EnvelopeIndex.build(
1564
+ configuration: @configuration, environment: environment, plugin_facts: effect_plugin_facts
1565
+ )
1566
+ end
1567
+ private :effect_envelope_index
1568
+
1164
1569
  def analyze_file_body(path, environment) # rubocop:disable Metrics/MethodLength
1165
1570
  parse_result = parse_source(path)
1166
1571
  unless parse_result.errors.empty?
@@ -1169,6 +1574,11 @@ module Rigor
1169
1574
  return parse_diagnostics(path, parse_result)
1170
1575
  end
1171
1576
 
1577
+ # ADR-103 WD13 — hand the effect collector the root the typer is about to walk, so its per-def
1578
+ # scan runs over exactly what was typed. Guarded inside the recorder rather than by `active?`: this
1579
+ # is a per-FILE site, so one `Thread.current` read is already nothing. The per-dispatch site in
1580
+ # `ExpressionTyper#call_type_for` is the one that pays for the integer fast path.
1581
+ Effects::Collector.record_root(parse_result.value)
1172
1582
  scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
1173
1583
  # ADR-24 slice 4a/4 — record unresolved implicit-self calls during the typing pass ONLY (not
1174
1584
  # CheckRules, whose own `type_of` queries would otherwise re-trigger the choke-point).