rigortype 0.4.3 → 0.4.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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -46,7 +46,8 @@ module Rigor
46
46
  synthetic_method_index:, project_patched_methods:,
47
47
  analyze_file:, project_scope_seed: -> { {} }, record_dependencies: false,
48
48
  restored_run_level_rows: nil, template_units: -> { TemplateUnits.empty },
49
- feature_sources: -> { {} }, plugin_prepare: -> { [[], []] })
49
+ feature_sources: -> { {} }, plugin_prepare: -> { [[], [], []] },
50
+ prepare_coordinator_plugins: -> { [] })
50
51
  @configuration = configuration
51
52
  @cache_store = cache_store
52
53
  @explain = explain
@@ -66,6 +67,12 @@ module Rigor
66
67
  # The de-duplication has to happen here, on the parent, because each fork worker owns its own
67
68
  # plugin instances and therefore its own `@run_disclosures` table.
68
69
  @collected_run_disclosures = {}
70
+ # Issue #1900 — the trust-policy refusals ({Analysis::WorkerSession#drain_trust_refusals}) a pool run
71
+ # collected from the pre-fork parent session and every worker, keyed by plugin id and summed. The
72
+ # coordinator-side registry, which {DiagnosticAggregator#plugin_trust_refusal_diagnostics} also
73
+ # reads, runs neither `#prepare` nor per-file analysis on a pooled run, so without this table a
74
+ # refusal recorded in `--workers 0` vanished in `--workers N`.
75
+ @collected_trust_refusals = {}
69
76
  @collect_stats = collect_stats
70
77
  @buffer = buffer
71
78
  @feature_sources = feature_sources
@@ -84,10 +91,15 @@ module Rigor
84
91
  # inherits the same compiled bytes.
85
92
  @template_units_reader = template_units
86
93
  @analyze_file = analyze_file
87
- # Issue #1541 — `() -> [rows, disclosure records]`, a `#prepare` run on a throwaway registry. Read
88
- # only by {#serve_empty_closure}: a pooled run's `#prepare` happens inside its workers, and an
89
- # empty closure dispatches none.
94
+ # Issue #1541 — `() -> [rows, disclosure records, trust refusals]`, a `#prepare` run on a throwaway
95
+ # registry. Read only by {#serve_empty_closure}: a pooled run's `#prepare` happens inside its
96
+ # workers, and an empty closure dispatches none.
90
97
  @plugin_prepare = plugin_prepare
98
+ # Issue #1900 — `() -> rows`, `#prepare` run on the coordinator-side registry itself. Read only by
99
+ # {#analyze_files_sequentially_fallback}, which analyses on that registry: the pre-pass skips
100
+ # `#prepare` whenever `pool_mode?`, so without this the fallback ran every plugin unprepared.
101
+ @prepare_coordinator_plugins = prepare_coordinator_plugins
102
+ @coordinator_plugins_prepared = false
91
103
  # Issues #796 / #794 — the previous full run's run-level rows, handed down by
92
104
  # {Analysis::IncrementalSession} on a NARROWED run only (a recheck's closure, a
93
105
  # `--verify-incremental` partition). A full run leaves this nil and derives everything itself.
@@ -99,6 +111,13 @@ module Rigor
99
111
  # so the incremental session's dependency graph is refreshed whether analysis ran sequential or pooled.
100
112
  attr_reader :collected_dependencies
101
113
 
114
+ # Issue #1553 — the per-file plugin reads the fork pool's workers recorded this run (empty unless
115
+ # `record_dependencies:` and a pool run occurred), folded into `Runner#analysis_reads`. Each worker's are
116
+ # marshalled back beside its dependency records.
117
+ def collected_analysis_reads
118
+ @collected_analysis_reads ||= {}
119
+ end
120
+
102
121
  # ADR-103 WD12 — the per-file {Effects::FileCollection}s the fork pool captured this run (empty
103
122
  # unless the configuration carries an `effects:` block and a pool run occurred). The runner merges
104
123
  # these with its own sequential records before the post-pool fixpoint.
@@ -123,6 +142,27 @@ module Rigor
123
142
  end
124
143
  end
125
144
 
145
+ # Issue #1900 — the trust refusals a pool run collected, one `{plugin_id:, count:, first_path:,
146
+ # nearest_root:}` per plugin, in first-merge order. Empty on a sequential run, where the runner's own
147
+ # registry made every read.
148
+ def collected_trust_refusals
149
+ @collected_trust_refusals.values
150
+ end
151
+
152
+ # Issue #1900 — folds one session's / one worker payload's refusal drain in: counts add, and the
153
+ # first path stays the one merged first. Callers merge in the order `--workers 0` would have made the
154
+ # reads — the parent's (or one worker's) `#prepare`, then the files in analysis order — so the summed
155
+ # row names the same first path the sequential run does, except where a drain's own first path is
156
+ # not this merge's to choose (see {DiagnosticAggregator#plugin_trust_refusal_diagnostics}).
157
+ def merge_trust_refusals(records)
158
+ Array(records).each do |record|
159
+ plugin_id = record[:plugin_id]
160
+ existing = @collected_trust_refusals[plugin_id]
161
+ @collected_trust_refusals[plugin_id] =
162
+ existing ? existing.merge(count: existing[:count] + record[:count]) : record
163
+ end
164
+ end
165
+
126
166
  # ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`. Editor mode (`buffer:` non-nil)
127
167
  # silently overrides pool mode to sequential: per design § "Ractor pool mode", the pool's warm-up
128
168
  # cost dominates one-file wall time, so the pool gains nothing on a per-buffer invocation. The
@@ -164,13 +204,17 @@ module Rigor
164
204
  if pool_mode?
165
205
  dispatch_pool(files, source_files: source_files)
166
206
  else
167
- analyze_files_sequentially(
168
- files, environment || resolve_sequential_environment(source_files: source_files)
169
- )
207
+ environment ||= resolve_sequential_environment(source_files: source_files)
208
+ analyze_files_sequentially(files, environment)
170
209
  end
171
210
  # Issues #796 / #794 — a narrowed run's own demand set is a SUBSET of the full run's, so the two
172
- # rows below are folded in after it, whichever backend produced the per-file stream.
173
- replayed_restored_run_level_rows?(closure: files)
211
+ # rows below are folded in after it, whichever backend produced the per-file stream. Issue #1929 —
212
+ # the sequential run's environment re-derives a stale replayed failure; a pool run has none on
213
+ # this side and resolves one only when a stale failure needs it.
214
+ redemand_env = lambda do
215
+ environment ||= resolve_sequential_environment(source_files: source_files)
216
+ end
217
+ replayed_restored_run_level_rows?(closure: files, redemand_env: redemand_env)
174
218
  result
175
219
  end
176
220
 
@@ -228,15 +272,18 @@ module Rigor
228
272
  # `env` rather than `resolve.call`: read the demand the snapshots above just made when they built
229
273
  # an environment, and force nothing when they did not.
230
274
  record_definition_build_failures(env&.rbs_loader&.definition_build_failures)
231
- record_hkt_scan_failure(hkt_scan_outcome(resolve.call)) unless replayed_restored_run_level_rows?
275
+ # An empty closure analyses no file, so only a deleted file's buffer can make a tuple stale here.
276
+ replayed = replayed_restored_run_level_rows?(closure: [], redemand_env: resolve)
277
+ record_hkt_scan_failure(hkt_scan_outcome(resolve.call)) unless replayed
232
278
  prepare_rows
233
279
  end
234
280
 
235
281
  # Issue #1541 — the pooled empty closure's `#prepare`: the rows go to the caller, the run-scoped
236
282
  # registrations into the table the workers' payloads would have filled.
237
283
  def pooled_prepare_rows
238
- rows, records = @plugin_prepare.call
284
+ rows, records, refusals = @plugin_prepare.call
239
285
  merge_run_disclosures(records)
286
+ merge_trust_refusals(refusals)
240
287
  Array(rows)
241
288
  end
242
289
 
@@ -285,18 +332,46 @@ module Rigor
285
332
  # of its own — and so no environment.
286
333
  #
287
334
  # @param closure — the files this run analyses; see {#stale_replayed_failure?}.
288
- def replayed_restored_run_level_rows?(closure: nil)
335
+ # @param redemand_env — resolves the environment {#redemand_stale_failures} re-derives a stale
336
+ # failure in; nil (the empty closure, which has no stale failure) re-derives nothing.
337
+ def replayed_restored_run_level_rows?(closure: nil, redemand_env: nil)
289
338
  rows = @restored_run_level_rows
290
339
  return false if rows.nil?
291
340
 
292
- replayable = Array(rows.definition_build_failures).reject do |failure|
341
+ stale, replayable = Array(rows.definition_build_failures).partition do |failure|
293
342
  stale_replayed_failure?(failure, closure)
294
343
  end
295
344
  record_definition_build_failures(replayable)
345
+ redemand_stale_failures(stale, redemand_env)
296
346
  record_hkt_scan_failure(rows.hkt_scan_failure)
297
347
  true
298
348
  end
299
349
 
350
+ # Issue #1929 — a stale replayed failure is RE-DERIVED, not dropped. Dropping it was right for the
351
+ # class whose collision the edit removed, and wrong for every class the edit left failing: the file
352
+ # that demanded such a class usually sits outside the recheck's closure (the edited file only
353
+ # contributes a conflicting buffer, such as a missing superclass every subclass inherits), so
354
+ # nothing in the recheck asks for it again, and the row lost classes a cold run reports. The class
355
+ # is asked for again in this run's environment ({Environment::RbsLoader#redemand_definition}), so
356
+ # it is reported exactly when it still fails, with the tuple the current signatures give it.
357
+ #
358
+ # A class this run's own analysis already reported is skipped; its tuple is current. The residue:
359
+ # a class whose only demand was in an edited file that no longer makes it, and which still fails,
360
+ # stays reported until a full run. The failure itself is real, unlike the dropped-collision case
361
+ # this replaced.
362
+ def redemand_stale_failures(stale, redemand_env)
363
+ return if stale.empty? || redemand_env.nil?
364
+
365
+ reported = @snapshots.definition_build_failures.to_set(&:first)
366
+ pending = stale.map(&:first).reject { |name| reported.include?(name) }
367
+ return if pending.empty?
368
+
369
+ loader = redemand_env.call&.rbs_loader
370
+ return if loader.nil?
371
+
372
+ record_definition_build_failures(pending.filter_map { |name| loader.redemand_definition(name) })
373
+ end
374
+
300
375
  # A replayed definition-build failure the snapshot's own fingerprint cannot vouch for. The
301
376
  # fingerprint digests the three signature sources it can read WITHOUT an environment — `sig:`,
302
377
  # `Gemfile.lock` / `rbs_collection.lock.yaml`, and the configuration — but NOT the virtual RBS a
@@ -306,11 +381,11 @@ module Rigor
306
381
  # closure, its analysis demands nothing that would re-derive the row, and the run reports a
307
382
  # collision between files that no longer collide.
308
383
  #
309
- # The narrowed run cannot re-derive the row either (#696 forbids Rigor demanding a definition the
310
- # analysis did not), so the honest answer is to DROP it and under-report rather than assert a
311
- # diagnostic on a program that may now be correct — false positives outrank worst-case reading. The
312
- # cost is bounded to the row's own conflicting buffers: a collision between two files neither of
313
- # which this run touches still replays.
384
+ # Such a failure is not replayed as it stands; {#redemand_stale_failures} re-derives it instead
385
+ # (#1929), so the row asserts no collision the current signatures do not have. A failure whose
386
+ # buffers name no file this run touches still replays. A buffer whose file no longer exists is stale
387
+ # too: deleting an inline source a tuple names would otherwise replay the deleted declaration's
388
+ # failure, where a full run reports what the remaining signatures give.
314
389
  def stale_replayed_failure?(failure, closure)
315
390
  return false if closure.nil?
316
391
 
@@ -320,7 +395,10 @@ module Rigor
320
395
  closure_paths = closure.to_set { |path| File.absolute_path(path.to_s) }
321
396
  Array(buffers).any? do |name|
322
397
  path = virtual_buffer_path(name)
323
- !path.nil? && closure_paths.include?(File.absolute_path(path))
398
+ next false if path.nil?
399
+
400
+ absolute = File.absolute_path(path)
401
+ closure_paths.include?(absolute) || !File.exist?(absolute)
324
402
  end
325
403
  end
326
404
 
@@ -639,13 +717,15 @@ module Rigor
639
717
  required_features: features
640
718
  )
641
719
  main = Ractor.main
642
- main.send([:prepare, session.prepare_diagnostics])
720
+ # Issue #1900 — each message carries the refusals made since the previous one, so the coordinator
721
+ # can count ONE worker's `#prepare` (every worker repeats it) and sum the files in file order.
722
+ main.send([:prepare, session.prepare_diagnostics, session.drain_trust_refusals])
643
723
 
644
724
  loop do
645
725
  msg = Ractor.receive
646
726
  break if msg.nil?
647
727
 
648
- main.send([:file, msg, session.analyze(msg)])
728
+ main.send([:file, msg, session.analyze(msg), session.drain_trust_refusals])
649
729
  end
650
730
 
651
731
  # Issue #1051 — sent with `:done`, not with `:prepare`: this backend builds no parent session,
@@ -664,6 +744,8 @@ module Rigor
664
744
  pool.each { |worker| worker.send(nil) }
665
745
 
666
746
  prepare_diagnostics = nil
747
+ prepare_refusals = nil
748
+ refusals_by_path = {}
667
749
  results_by_path = {}
668
750
  terminated = 0
669
751
 
@@ -681,9 +763,11 @@ module Rigor
681
763
 
682
764
  case message.first
683
765
  when :prepare
684
- prepare_diagnostics ||= message.last
766
+ prepare_diagnostics ||= message[1]
767
+ prepare_refusals ||= message[2]
685
768
  when :file
686
769
  results_by_path[message[1]] = message[2]
770
+ refusals_by_path[message[1]] = message[3]
687
771
  when :done
688
772
  merge_worker_reporters(message[1])
689
773
  merge_run_disclosures(message[2])
@@ -702,6 +786,8 @@ module Rigor
702
786
  # it did. Re-analysed in process, exactly as the fork backend re-analyses a dead child's slice.
703
787
  degraded = files.reject { |path| results_by_path.key?(path) }
704
788
  reanalyze_degraded_in_process(degraded, results_by_path, source_files: source_files)
789
+ merge_trust_refusals(prepare_refusals)
790
+ files.each { |path| merge_trust_refusals(refusals_by_path[path]) }
705
791
 
706
792
  diagnostics = Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
707
793
  degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "ractor"))
@@ -795,37 +881,62 @@ module Rigor
795
881
  # `merge_run_disclosures` drops it as a duplicate; only a disclosure a worker registers while
796
882
  # analysing its own slice adds anything.
797
883
  merge_run_disclosures(session.drain_run_disclosures)
884
+ # Issue #1900 — the parent's `#prepare` refusals, drained BEFORE the fork: every child inherits the
885
+ # session's record of what it already reported and drains only its own slice's, so the prepare-time
886
+ # refusal is counted once, as `--workers 0` counts it, rather than once per worker.
887
+ merge_trust_refusals(session.drain_trust_refusals)
798
888
  snapshot_fork_pool_stats(session) if @collect_stats
799
889
 
800
890
  worker_count = [@workers, files.size].min
801
891
  slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
802
892
  results_by_path = {}
893
+ # Issue #1900 — each surviving child's refusal drain by slice index, and each re-analysed file's by
894
+ # path, merged below in file order so the first refused path is the one `--workers 0` names.
895
+ refusals_by_slice = {}
896
+ refusals_by_path = {}
803
897
 
804
898
  degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
805
899
  children = slices.each_with_index.map do |slice, index|
806
900
  out_path = File.join(tmpdir, "worker-#{index}")
807
901
  { pid: fork { run_fork_worker(session, slice, out_path) },
808
- slice: slice, out_path: out_path }
902
+ slice: slice, out_path: out_path, index: index }
809
903
  end
810
- collect_fork_results(children, results_by_path)
904
+ collect_fork_results(children, results_by_path, refusals_by_slice: refusals_by_slice)
811
905
  end
812
906
 
813
907
  unless degraded.empty?
814
- degraded.each { |path| results_by_path[path] = session.analyze(path) }
908
+ degraded.each do |path|
909
+ results_by_path[path] = session.analyze(path)
910
+ refusals_by_path[path] = session.drain_trust_refusals
911
+ end
815
912
  merge_worker_reporters(session.drain_reporters)
816
913
  # The degraded slice ran on the parent session, so a disclosure it registered lives there.
817
914
  merge_run_disclosures(session.drain_run_disclosures)
818
915
  # The degraded slice was re-analysed on the parent session, so its dependency records live there,
819
916
  # not in a child payload — fold them in alongside the successful children's.
820
- @collected_dependencies.merge!(session.drain_dependencies) if @record_dependencies
917
+ collect_dependencies(session.drain_dependencies, session.drain_analysis_reads) if @record_dependencies
821
918
  @collected_effects.merge!(session.drain_effects) if @record_effects
822
919
  end
823
920
 
921
+ merge_slice_refusals(slices, refusals_by_slice, refusals_by_path)
824
922
  diagnostics = Array(session.prepare_diagnostics) +
825
923
  files.flat_map { |path| results_by_path.fetch(path, []) }
826
924
  degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "fork"))
827
925
  end
828
926
 
927
+ # Issue #1900 — a surviving child's drain covers its whole slice; a degraded slice was re-analysed on
928
+ # the parent and drained file by file. Slices are contiguous runs of `files`, so walking them in index
929
+ # order merges every refusal in the order the sequential run made it.
930
+ def merge_slice_refusals(slices, refusals_by_slice, refusals_by_path)
931
+ slices.each_with_index do |slice, index|
932
+ if refusals_by_slice.key?(index)
933
+ merge_trust_refusals(refusals_by_slice[index])
934
+ else
935
+ slice.each { |path| merge_trust_refusals(refusals_by_path[path]) }
936
+ end
937
+ end
938
+ end
939
+
829
940
  # Child-process body for {#analyze_files_in_fork_pool}. Analyses the slice with the
830
941
  # copy-on-write-inherited session and writes the Marshal'd payload to `out_path`. `exit!` skips
831
942
  # `at_exit` / stdio flush — the payload is already durable on disk by then.
@@ -840,7 +951,9 @@ module Rigor
840
951
  results = slice.to_h { |path| [path, session.analyze(path)] }
841
952
  payload = { results: results, reporters: session.drain_reporters,
842
953
  dependencies: session.drain_dependencies,
843
- disclosures: session.drain_run_disclosures }
954
+ analysis_reads: session.drain_analysis_reads,
955
+ disclosures: session.drain_run_disclosures,
956
+ refusals: session.drain_trust_refusals }
844
957
  # ADR-103 WD13 — the effects slot exists only on a collecting run, so a normal `--workers N` run
845
958
  # marshals exactly the payload it marshalled before the feature existed.
846
959
  payload[:effects] = session.drain_effects if @record_effects
@@ -864,7 +977,9 @@ module Rigor
864
977
 
865
978
  # Waits for every forked child, merges each successful payload into `results_by_path`, and returns
866
979
  # the file paths whose worker exited abnormally (for in-process degrade).
867
- def collect_fork_results(children, results_by_path)
980
+ # @param refusals_by_slice — issue #1900 — receives each successful payload's trust-refusal drain
981
+ # under the child's slice index, for the caller to merge in file order; nil merges it at once.
982
+ def collect_fork_results(children, results_by_path, refusals_by_slice: nil)
868
983
  degraded = []
869
984
  children.each do |child|
870
985
  _, status = Process.waitpid2(child[:pid])
@@ -873,7 +988,11 @@ module Rigor
873
988
  results_by_path.merge!(payload.fetch(:results))
874
989
  merge_worker_reporters(payload.fetch(:reporters))
875
990
  merge_run_disclosures(payload.fetch(:disclosures, []))
876
- @collected_dependencies.merge!(payload.fetch(:dependencies, {})) if @record_dependencies
991
+ refusals = payload.fetch(:refusals, [])
992
+ refusals_by_slice ? refusals_by_slice[child[:index]] = refusals : merge_trust_refusals(refusals)
993
+ if @record_dependencies
994
+ collect_dependencies(payload.fetch(:dependencies, {}), payload.fetch(:analysis_reads, {}))
995
+ end
877
996
  @collected_effects.merge!(payload.fetch(:effects, {})) if @record_effects
878
997
  else
879
998
  degraded.concat(child[:slice])
@@ -882,6 +1001,12 @@ module Rigor
882
1001
  degraded
883
1002
  end
884
1003
 
1004
+ # ADR-46 / issue #1553 — fold one worker's dependency records and per-file plugin reads.
1005
+ def collect_dependencies(dependencies, analysis_reads)
1006
+ @collected_dependencies.merge!(dependencies)
1007
+ collected_analysis_reads.merge!(analysis_reads)
1008
+ end
1009
+
885
1010
  # @return the child's `{results:, reporters:}` payload, or nil when the child exited
886
1011
  # abnormally or wrote no readable payload. `Marshal.load` is safe here: the blob was written by
887
1012
  # our own forked child to a temp file we created.
@@ -957,7 +1082,15 @@ module Rigor
957
1082
  # @param source_files — issue #793 — the whole project, so this path's environment
958
1083
  # carries the same plugin-synthesized RBS the pool workers' would. It used to build over `[]`,
959
1084
  # i.e. with no synthesized RBS at all, even on a full run.
1085
+ #
1086
+ # Issue #1900 — it also runs `#prepare` on the coordinator-side registry it analyses with, BEFORE the
1087
+ # environment build as the sequential pre-pass does. A pooled run's pre-pass skips `#prepare` (the
1088
+ # workers run it), and this path has no workers, so every plugin used to analyse unprepared and its
1089
+ # prepare rows and prepare-time trust refusals were lost. The rows head the stream, where the pool
1090
+ # backends put their workers' prepare rows; the refusals land on the coordinator's own
1091
+ # {Plugin::IoBoundary}s, which {DiagnosticAggregator#plugin_trust_refusal_diagnostics} reads.
960
1092
  def analyze_files_sequentially_fallback(files, reason:, source_files: files)
1093
+ prepare_rows = prepare_coordinator_plugins_once
961
1094
  environment = build_runner_environment(source_files: source_files)
962
1095
  # Same set, same ORDER as `#analyze_files_sequentially`. The signature state used to be hand-copied
963
1096
  # here slot by slot, which dropped `synthesized-namespace` and every conformance row. The conformance
@@ -965,7 +1098,7 @@ module Rigor
965
1098
  # BEFORE the definition-build failures are read below, or a failure only that scan reaches is lost.
966
1099
  snapshot_project_signature_state(environment)
967
1100
  snapshot_effect_annotation_carrier(environment.rbs_loader)
968
- diagnostics = files.flat_map { |path| @analyze_file.call(path, environment) }
1101
+ diagnostics = prepare_rows + files.flat_map { |path| @analyze_file.call(path, environment) }
969
1102
  loader = environment.rbs_loader
970
1103
  # Issue #696 — this path analyses on the coordinator, so its loader IS the one that reached the
971
1104
  # failing build, and it must snapshot the same slot the two real paths do. Reached whenever
@@ -987,6 +1120,15 @@ module Rigor
987
1120
  )
988
1121
  end
989
1122
 
1123
+ # Issue #1900 — `#prepare` on the coordinator-side registry, at most once per run: a second call would
1124
+ # re-publish every cross-plugin fact into the shared fact store.
1125
+ def prepare_coordinator_plugins_once
1126
+ return [] if @coordinator_plugins_prepared
1127
+
1128
+ @coordinator_plugins_prepared = true
1129
+ Array(@prepare_coordinator_plugins.call)
1130
+ end
1131
+
990
1132
  def merge_worker_reporters(drained)
991
1133
  merge_rbs_extended_reporter(drained.fetch(:rbs_extended))
992
1134
  drained.fetch(:boundary_cross).each do |entry|
@@ -1081,8 +1223,14 @@ module Rigor
1081
1223
  def record_definition_build_failures(failures)
1082
1224
  return if failures.nil? || failures.empty?
1083
1225
 
1226
+ # Issue #1902 — one tuple per class, chosen by {Environment::RbsLoader.definition_build_detail_order}
1227
+ # rather than by arrival: a fresh record and a replayed one (or two workers' records) for the same
1228
+ # class can name different failures, and the earlier one used to win.
1084
1229
  @snapshots.definition_build_failures =
1085
- (@snapshots.definition_build_failures + failures).uniq(&:first).freeze
1230
+ (@snapshots.definition_build_failures + failures)
1231
+ .group_by(&:first)
1232
+ .map { |_, candidates| candidates.min_by { |c| Environment::RbsLoader.definition_build_detail_order(c) } }
1233
+ .freeze
1086
1234
  end
1087
1235
 
1088
1236
  # Issue #784 — first-wins, unlike {#record_definition_build_failures}'s accumulate-and-dedup. That
@@ -8,6 +8,7 @@ require_relative "../../inference/synthetic_method_scanner"
8
8
  require_relative "../../inference/project_patched_scanner"
9
9
  require_relative "../dependency_source_inference"
10
10
  require_relative "../project_scan"
11
+ require_relative "../worker_session"
11
12
  require_relative "../template_units"
12
13
  require_relative "../template_unit_collector"
13
14
 
@@ -256,11 +257,15 @@ module Rigor
256
257
  # Issue #1541 — what a pooled run's workers would have produced from `#prepare`, for the coordinator's
257
258
  # empty-closure branch, which dispatches none: the `#prepare` error rows and the run-scoped
258
259
  # registrations (`#disclose_once` / `#emit_once`) made there. It runs on a THROWAWAY registry, so
259
- # nothing `#prepare` records on a plugin instance (a trust refusal, say) reaches the coordinator-side
260
- # instances {DiagnosticAggregator} reads — a pooled cold run never shows those either.
261
- # Returns the rows first, then the disclosure records.
260
+ # nothing `#prepare` records on a plugin instance reaches the coordinator-side instances
261
+ # {DiagnosticAggregator} reads; issue #1900 — the trust refusals are therefore handed back too, in
262
+ # the shape {Analysis::WorkerSession#drain_trust_refusals} gives a pooled cold run's.
263
+ # Returns the rows first, then the disclosure records, then the refusals.
262
264
  def prepare_throwaway_registry
263
265
  registry = load_plugins
266
+ # Issue #1900 — `#init` already ran (in `load_plugins`) and its refusals are the coordinator-side
267
+ # registry's to report, so only what `#prepare` refuses from here on is handed back.
268
+ baseline = WorkerSession.refusal_counts(registry)
264
269
  rows = plugin_prepare_diagnostics(registry)
265
270
  records = registry.plugins.flat_map do |plugin|
266
271
  plugin_id = safe_plugin_id(plugin)
@@ -268,7 +273,14 @@ module Rigor
268
273
  rescue StandardError
269
274
  []
270
275
  end
271
- [rows, records]
276
+ [rows, records, trust_refusals(registry, baseline)]
277
+ end
278
+
279
+ # Issue #1900 — `#prepare` on `plugin_registry` itself, returning its error rows. For the pool
280
+ # coordinator's sequential fallback, which analyses on the coordinator-side registry that {#run}
281
+ # left unprepared because the run is in pool mode.
282
+ def prepare_registry(plugin_registry)
283
+ plugin_prepare_diagnostics(plugin_registry)
272
284
  end
273
285
 
274
286
  # Returns the per-run shared `Plugin::FactStore` instance. All loaded plugins share this store
@@ -282,6 +294,19 @@ module Rigor
282
294
 
283
295
  private
284
296
 
297
+ def trust_refusals(registry, baseline)
298
+ registry.plugins.filter_map do |plugin|
299
+ summary = plugin.io_boundary.refusal_summary
300
+ next nil if summary.nil?
301
+
302
+ plugin_id = safe_plugin_id(plugin)
303
+ delta = summary[:count] - baseline.fetch(plugin_id, 0)
304
+ summary.merge(count: delta, plugin_id: plugin_id) if delta.positive?
305
+ rescue StandardError
306
+ nil
307
+ end
308
+ end
309
+
285
310
  def pool_mode?
286
311
  @pool_mode_reader.call
287
312
  end