rigortype 0.4.2 → 0.4.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -48,11 +48,13 @@ module Rigor
48
48
  # @param cache_path — the configuration's cache root; nil or empty is an inert
49
49
  # cache (every fetch computes, nothing persists).
50
50
  # @param target_ruby — parse-affecting configuration, part of the identity.
51
- def self.open(cache_path, target_ruby: nil)
51
+ # `root` names the project and working directory a run reads roles and spellings from (#1760): a
52
+ # scan cached by a run from another directory carries another role, so it is never served here.
53
+ def self.open(cache_path, target_ruby: nil, root: nil)
52
54
  return new(path: nil, header: nil) if cache_path.nil? || cache_path.to_s.empty?
53
55
 
54
56
  new(path: File.join(cache_path.to_s, FILE_NAME),
55
- header: "#{SCHEMA}:#{Rigor::VERSION}:#{target_ruby}:#{source_digest}")
57
+ header: "#{SCHEMA}:#{Rigor::VERSION}:#{target_ruby}:#{source_digest}:#{root}")
56
58
  end
57
59
 
58
60
  def self.source_digest
@@ -94,7 +94,12 @@ module Rigor
94
94
  does_not_fire_when: [
95
95
  "Implicit-self calls (no receiver) — too noisy without per-method RBS for every helper.",
96
96
  "Receiver is `Dynamic[T]` / `Top` / `Union` — by definition the method set isn't enumerable.",
97
- "Receiver class is in the loader but its RBS definition cannot be built (constant aliases)."
97
+ "Receiver class is in the loader but its RBS definition cannot be built (constant aliases).",
98
+ "Receiver class answers beyond any signature: a plugin-declared open receiver (ADR-26), or one " \
99
+ "Rigor's bundled overlays name (`OpenStruct`, whose fields are per-instance singleton methods; " \
100
+ "`ActiveSupport::Duration` and `ActiveSupport::Concern` when their gem overlay loads). Both sides " \
101
+ "of the class are open, and an inherited Object / Kernel signature no longer decides " \
102
+ "`call.wrong-arity` or `call.argument-type-mismatch` on it."
98
103
  ],
99
104
  suppression: "`# rigor:disable call.undefined-method` on the call line, " \
100
105
  "or `disable: [\"call.undefined-method\"]` in `.rigor.yml`.",
@@ -70,13 +70,14 @@ module Rigor
70
70
  RUN_EFFECTS_TABLE_PRODUCER_ID = "analysis.run-effects-table"
71
71
 
72
72
  # The run-result producer's declared compaction budget (`Cache::Store#evict!` pass 2). Whole-project,
73
- # but unlike the `rbs.*` producers several generations can be live at once: the `paths` key slot means
74
- # one entry per analyzed-path SET, so `rigor check` over the whole project, over `lib`, and over a
75
- # single file are three separate live generations. 16 is a judgement call sized for that churn; it is
76
- # a cap on GENERATIONS, not on correctness — over-evicting here costs a recompute, never a wrong
77
- # answer. Measured 2026-07-25 against a real project's `.rigor/cache`: the cap does bind (60 distinct
78
- # path-set generations observed, differing in the `paths` slot alone), with no evidence yet on how
79
- # often an evicted generation is asked for again — see issue #151.
73
+ # but unlike the `rbs.*` producers several generations can be live at once: the `paths` and `roots` key
74
+ # slots mean one entry per analyzed-path SET x root list as written (#1559), so `rigor check` over the
75
+ # whole project, over `lib`, and over a single file are three separate live generations, and `rigor
76
+ # check` and `rigor check lib` are two even when `lib` is the configured `paths:`. 16 is a judgement call
77
+ # sized for that churn; it is a cap on GENERATIONS, not on correctness — over-evicting here costs a
78
+ # recompute, never a wrong answer. Measured 2026-07-25 against a real project's `.rigor/cache`: the cap
79
+ # does bind (60 distinct path-set generations observed, differing in the `paths` slot alone), with no
80
+ # evidence yet on how often an evicted generation is asked for again — see issue #151.
80
81
  GENERATION_CAP = 16
81
82
 
82
83
  # The effects sidecar's own compaction budget. One generation per (path set × effects identity), and
@@ -109,6 +110,29 @@ module Rigor
109
110
  nil
110
111
  end
111
112
 
113
+ # #1559 — the run-result key's analysis ROOTS, as the run was given them. The `paths` slot holds only the
114
+ # files the roots expand to, so `check lib extra` with `extra` missing and `check lib` shared a key while
115
+ # only the first prints `extra:1:1: warning: no such file or directory (skipped)`, which changes the exit
116
+ # code under `--fail-on=warning`. The roots are kept in the order and spelling written, not normalised: a
117
+ # missing root is reported as written (`./extra` is not `extra`), and the order of the roots is the order
118
+ # the diagnostics come out in, so two runs that differ only in either are two slots (a forgone hit, never a
119
+ # wrong one).
120
+ #
121
+ # Added to the DIAGNOSTICS key only. The ADR-103 effects sidecar derives its identity from {#descriptor}
122
+ # without it: the effect table is a function of the analysed files, and no path-error row reaches it.
123
+ # The `--incremental` slot builds its own, normalised, roots entry ({IncrementalRunSlot}).
124
+ #
125
+ # @param descriptor — a {#descriptor} result (nil passes through, so a disabled cache stays disabled).
126
+ def with_roots(descriptor, roots)
127
+ return nil if descriptor.nil?
128
+
129
+ entry = config_entry("roots", Marshal.dump(Array(roots).map(&:to_s)))
130
+ Cache::Descriptor.new(
131
+ gems: descriptor.gems, dependencies: descriptor.dependencies, plugins: descriptor.plugins,
132
+ configs: descriptor.configs + [entry]
133
+ )
134
+ end
135
+
112
136
  # Issue #285 — the `engine` slot above pins the engine by VERSION, which identifies the source only
113
137
  # for a released gem. A checkout (a contributor's, or a `bundle add rigor, github:` clone) gets one
114
138
  # extra slot carrying a digest of the engine's own source, so editing `lib/rigor/inference/*.rb` no
@@ -64,10 +64,12 @@ module Rigor
64
64
  # the caller loads the engine and runs the full path. Any failure declines rather than raising: the
65
65
  # probe must never turn a servable run into a crash.
66
66
  def serve(paths)
67
- files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns)
68
- key = RunCacheKey.descriptor(
69
- configuration: @configuration, files: files, explain: @explain,
70
- rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)
67
+ files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns, base: @configuration.exclude_base)
68
+ key = RunCacheKey.with_roots(
69
+ RunCacheKey.descriptor(
70
+ configuration: @configuration, files: files, explain: @explain,
71
+ rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)
72
+ ), paths
71
73
  )
72
74
  return nil if key.nil?
73
75
 
@@ -24,18 +24,31 @@ module Rigor
24
24
  # `class_decl_paths` (a frozen `Hash<String, String>` populated once per environment by the RBS loader;
25
25
  # ~1000-2000 entries × one `String#start_with?`).
26
26
  class RunStats
27
- attr_reader :wall_seconds, :peak_rss_bytes,
27
+ attr_reader :wall_seconds,
28
28
  :target_files,
29
29
  :rbs_classes_total, :rbs_classes_project_sig, :rbs_classes_bundled,
30
30
  :gem_walk_classes, :gem_walk_gems, :rbs_attribution_available
31
31
 
32
- def initialize(wall_seconds:, peak_rss_bytes:, # rubocop:disable Metrics/ParameterLists
32
+ # The live probe a run's summary uses: reads this process's RSS when first asked. The probe is a constant
33
+ # lambda, so the RunStats it rides on never captures the Runner (and its analysis state) through `Result`.
34
+ CURRENT_PROCESS_PEAK_RSS = Ractor.make_shareable(-> { RunStats.peak_rss_bytes })
35
+
36
+ # Issue #1529 — the peak RSS is read on the first {#peak_rss_bytes} call, not when the summary is built. A run
37
+ # that prints no summary (`--incremental` recheck, `--no-stats`) therefore never forks `ps` or reads `/proc`.
38
+ # The read stays in this process: the summary is built and printed by the process that ran the analysis.
39
+ #
40
+ # @param peak_rss_bytes — a value to report directly (specs, or a caller that already read it). Ignored when
41
+ # `peak_rss_probe:` is given.
42
+ # @param peak_rss_probe — a zero-argument callable returning the RSS in bytes, called at most once, lazily.
43
+ def initialize(wall_seconds:, # rubocop:disable Metrics/ParameterLists
33
44
  target_files:,
34
45
  rbs_classes_total:, rbs_classes_project_sig:, rbs_classes_bundled:,
35
46
  gem_walk_classes:, gem_walk_gems:,
36
- rbs_attribution_available: true)
47
+ rbs_attribution_available: true, peak_rss_bytes: nil, peak_rss_probe: nil)
37
48
  @wall_seconds = wall_seconds
38
- @peak_rss_bytes = peak_rss_bytes
49
+ @peak_rss_probe = peak_rss_probe || -> { peak_rss_bytes }
50
+ # A frozen RunStats cannot memoise into an ivar, so the first read lands in this (unfrozen) Hash.
51
+ @peak_rss_memo = {}
39
52
  @target_files = target_files
40
53
  @rbs_classes_total = rbs_classes_total
41
54
  @rbs_classes_project_sig = rbs_classes_project_sig
@@ -46,6 +59,15 @@ module Rigor
46
59
  freeze
47
60
  end
48
61
 
62
+ # The peak RSS in bytes, read once on first call and memoised (nil when no route answers). The check command
63
+ # forces the first call straight after the analysis (#1529), before `--coverage` or any output, so the figure
64
+ # covers the analysis alone. On Linux it is the kernel's VmHWM high-water mark, so a later read is never below
65
+ # the run-end value. On macOS it is `ps`'s current RSS, so the reading is the process as it stood at that
66
+ # first call, not a high-water mark.
67
+ def peak_rss_bytes
68
+ @peak_rss_memo.fetch(:value) { @peak_rss_memo[:value] = @peak_rss_probe.call }
69
+ end
70
+
49
71
  # Reports the process's resident set size in bytes. Source ordering: `/proc/self/status` (Linux — reads
50
72
  # `VmHWM:`, the peak RSS the kernel records) first; otherwise `ps -o rss= -p <pid>` (macOS / BSD —
51
73
  # reports CURRENT RSS, the closest universally-available proxy). Returns nil when neither route works
@@ -146,7 +168,7 @@ module Rigor
146
168
  end
147
169
  out.puts("#{prefix}Process")
148
170
  out.puts("#{prefix} Wall time: #{Kernel.format('%.2fs', @wall_seconds)}")
149
- out.puts("#{prefix} Memory peak: #{format_bytes(@peak_rss_bytes)}")
171
+ out.puts("#{prefix} Memory peak: #{format_bytes(peak_rss_bytes)}")
150
172
  end
151
173
 
152
174
  def to_h
@@ -159,7 +181,7 @@ module Rigor
159
181
  gem_walk_classes: @gem_walk_classes,
160
182
  gem_walk_gems: @gem_walk_gems,
161
183
  wall_seconds: @wall_seconds,
162
- peak_rss_bytes: @peak_rss_bytes
184
+ peak_rss_bytes: peak_rss_bytes
163
185
  }
164
186
  end
165
187
 
@@ -57,7 +57,8 @@ module Rigor
57
57
  quarantined_signatures_snapshot:, env_build_failure_snapshot:,
58
58
  definition_build_failures_snapshot:, hkt_scan_failure_snapshot:,
59
59
  conformance_results_snapshot:, signature_standdowns_snapshot: -> { [] },
60
- vendored_standdowns_snapshot: -> { [] }, pooled_run_disclosures: -> { [] })
60
+ vendored_standdowns_snapshot: -> { [] }, pooled_run_disclosures: -> { [] },
61
+ pooled_trust_refusals: -> { [] })
61
62
  @configuration = configuration
62
63
  @rbs_extended_reporter = rbs_extended_reporter
63
64
  @boundary_cross_reporter = boundary_cross_reporter
@@ -78,6 +79,9 @@ module Rigor
78
79
  # Issue #1051 — reader for the pool coordinator's de-duplicated run-scoped disclosure table.
79
80
  # Empty on a sequential run, where `plugin_registry`'s own instances are the ones that registered.
80
81
  @pooled_run_disclosures_reader = pooled_run_disclosures
82
+ # Issue #1900 — reader for the pool coordinator's summed trust-refusal table. Empty on a
83
+ # sequential run, where `plugin_registry`'s own boundaries made every read.
84
+ @pooled_trust_refusals_reader = pooled_trust_refusals
81
85
  end
82
86
 
83
87
  # Pre-file diagnostic streams that fire once per run rather than per analyzed file: plugin load /
@@ -128,7 +132,32 @@ module Rigor
128
132
  source_family: :builtin
129
133
  )
130
134
  end
131
- not_found + Array(pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
135
+ scanned = Array(pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
136
+ suppress_pre_eval(not_found + scanned)
137
+ end
138
+
139
+ # The ordinary suppression pipeline for the `pre-eval.*` rows: the `# rigor:disable` /
140
+ # `# rigor:disable-file` comments of the file a row is positioned in (a `pre_eval:` file need not be
141
+ # under `paths:`, so its comments are read here rather than by per-file analysis) and the project's
142
+ # `disable:` list. A row positioned at `.rigor.yml` has no Ruby comments (`.yml` is skipped) and answers to
143
+ # `disable:` only.
144
+ # Stream order is the output contract, so survivors are picked out of the original sequence.
145
+ def suppress_pre_eval(diagnostics)
146
+ kept = diagnostics.group_by(&:path).flat_map do |path, group|
147
+ CheckRules.filter_suppressed(
148
+ group, comments: pre_eval_comments(path), disabled_rules: @configuration.disabled_rules
149
+ )
150
+ end
151
+ kept_ids = kept.to_set(&:object_id)
152
+ diagnostics.select { |diagnostic| kept_ids.include?(diagnostic.object_id) }
153
+ end
154
+
155
+ def pre_eval_comments(path)
156
+ return [].freeze unless File.file?(path) && !path.end_with?(".rbs", ".yml", ".yaml")
157
+
158
+ Prism.parse_file(path).comments
159
+ rescue StandardError
160
+ [].freeze
132
161
  end
133
162
 
134
163
  def diagnostic_from_hash(hash)
@@ -588,9 +617,18 @@ module Rigor
588
617
  # culprit and reads the same on a cache hit, where every `RBS::Location` has been dumped to a
589
618
  # sentinel. The error class is attributed to that first failure explicitly rather than to all of
590
619
  # them: the per-class memo keeps only the first reason, and one run can mix error classes.
620
+ #
621
+ # Issue #1902 — "first" is first by CLASS NAME, not first recorded. The snapshot arrives in demand
622
+ # order, and that order is a property of the invocation, not of the project: an `--incremental`
623
+ # recheck records the classes its closure demanded before it unions in the replayed set, and a worker
624
+ # pool unions per-worker lists in drain order. So the classes are sorted by name and the files
625
+ # sorted and deduplicated here. Which tuple each class carries is fixed upstream
626
+ # ({Environment::RbsLoader.definition_build_detail_order}), not here.
591
627
  def build_rbs_definition_build_failed_diagnostic(failures)
628
+ failures = failures.sort_by { |failure| failure.first.to_s }
592
629
  sample_size = 5
593
- files = failures.flat_map { |failure| Array(failure[3]) }.uniq.map { |name| relative_signature_path(name) }
630
+ files = failures.flat_map { |failure| Array(failure[3]) }
631
+ .map { |name| relative_signature_path(name) }.uniq.sort
594
632
  Diagnostic.new(
595
633
  path: ".rigor.yml",
596
634
  line: 1,
@@ -1059,16 +1097,22 @@ module Rigor
1059
1097
  # cache"). It is also never routed through `IncrementalSession`'s per-file cache (`#788`): that cache
1060
1098
  # holds only `Runner#per_file_diagnostics`, and this row is not part of that stream.
1061
1099
  #
1062
- # Scope: `plugin_registry` here is the coordinator-side registry, which only actually RUNS `#prepare`
1063
- # / per-file analysis in sequential mode (`--workers` unset, the default) — a pooled run (`--workers
1064
- # N`) prepares and analyses on per-worker `WorkerSession` registries this reader never sees, so a
1065
- # refusal confined to a pooled worker's slice is not reported. Left out deliberately: draining it
1066
- # needs a fourth marshalled channel beside `drain_reporters` / `drain_dependencies` / `drain_effects`
1067
- # in `Runner::PoolCoordinator`, and the default (unpooled) path already covers the reported bug.
1100
+ # Two sources (issue #1900), summed per plugin: the coordinator-side registry's own boundaries — where
1101
+ # a sequential run, and the pool's no-worker fallback, made every read — and `pooled_trust_refusals`,
1102
+ # what a pooled run drained from its pre-fork parent session and every worker
1103
+ # ({Runner::PoolCoordinator#merge_trust_refusals}). The count is per refused ATTEMPT, as
1104
+ # `--workers 0` always counted it; the pool drains the parent's `#prepare` refusals before forking and
1105
+ # each worker reports only what it added, so the workers, which inherit the parent's boundaries, do
1106
+ # not count them again. Rows follow registry
1107
+ # (plugin load) order, which every mode shares.
1068
1108
  def plugin_trust_refusal_diagnostics
1069
1109
  return [] if plugin_registry.empty?
1070
1110
 
1071
- plugin_registry.plugins.filter_map { |plugin| plugin_trust_refusal_diagnostic(plugin) }
1111
+ pooled = Array(pooled_trust_refusals).to_h { |record| [record[:plugin_id], record] }
1112
+ plugin_registry.plugins.filter_map do |plugin|
1113
+ summary = combined_refusal_summary(pooled[disclosure_plugin_id(plugin)], plugin)
1114
+ plugin_trust_refusal_diagnostic(plugin, summary) if summary
1115
+ end
1072
1116
  end
1073
1117
 
1074
1118
  # Issue #1051 — the run-scoped plugin disclosures ({Plugin::Base#disclose_once}), the run's
@@ -1168,10 +1212,23 @@ module Rigor
1168
1212
  plugin.class.to_s
1169
1213
  end
1170
1214
 
1171
- def plugin_trust_refusal_diagnostic(plugin)
1172
- summary = plugin.io_boundary.refusal_summary
1173
- return nil if summary.nil?
1215
+ # The coordinator's own record first when it has one: on a pooled run it holds the `#init` reads
1216
+ # (every registry runs `#init`; the workers' drains start after it), which `--workers 0` makes before
1217
+ # `#prepare` and before any file, so its first path is the one a sequential run names. Not so after a
1218
+ # Ractor-backend degrade, where this record also holds the re-analysed files' refusals and is named
1219
+ # ahead of a worker's `#prepare` path; the count is right either way.
1220
+ def combined_refusal_summary(pooled, plugin)
1221
+ local = begin
1222
+ plugin.io_boundary.refusal_summary
1223
+ rescue StandardError
1224
+ nil
1225
+ end
1226
+ return local || pooled if pooled.nil? || local.nil?
1174
1227
 
1228
+ local.merge(count: local[:count] + pooled[:count])
1229
+ end
1230
+
1231
+ def plugin_trust_refusal_diagnostic(plugin, summary)
1175
1232
  plugin_id = plugin.manifest.id
1176
1233
  Diagnostic.new(
1177
1234
  path: ".rigor.yml", line: 1, column: 1,
@@ -1258,6 +1315,10 @@ module Rigor
1258
1315
  @pooled_run_disclosures_reader.call
1259
1316
  end
1260
1317
 
1318
+ def pooled_trust_refusals
1319
+ @pooled_trust_refusals_reader.call
1320
+ end
1321
+
1261
1322
  def pre_eval_diagnostics_from_scanner
1262
1323
  @pre_eval_diagnostics_from_scanner_reader.call
1263
1324
  end
@@ -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: -> { {} })
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,6 +91,15 @@ module Rigor
84
91
  # inherits the same compiled bytes.
85
92
  @template_units_reader = template_units
86
93
  @analyze_file = analyze_file
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.
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
87
103
  # Issues #796 / #794 — the previous full run's run-level rows, handed down by
88
104
  # {Analysis::IncrementalSession} on a NARROWED run only (a recheck's closure, a
89
105
  # `--verify-incremental` partition). A full run leaves this nil and derives everything itself.
@@ -119,6 +135,27 @@ module Rigor
119
135
  end
120
136
  end
121
137
 
138
+ # Issue #1900 — the trust refusals a pool run collected, one `{plugin_id:, count:, first_path:,
139
+ # nearest_root:}` per plugin, in first-merge order. Empty on a sequential run, where the runner's own
140
+ # registry made every read.
141
+ def collected_trust_refusals
142
+ @collected_trust_refusals.values
143
+ end
144
+
145
+ # Issue #1900 — folds one session's / one worker payload's refusal drain in: counts add, and the
146
+ # first path stays the one merged first. Callers merge in the order `--workers 0` would have made the
147
+ # reads — the parent's (or one worker's) `#prepare`, then the files in analysis order — so the summed
148
+ # row names the same first path the sequential run does, except where a drain's own first path is
149
+ # not this merge's to choose (see {DiagnosticAggregator#plugin_trust_refusal_diagnostics}).
150
+ def merge_trust_refusals(records)
151
+ Array(records).each do |record|
152
+ plugin_id = record[:plugin_id]
153
+ existing = @collected_trust_refusals[plugin_id]
154
+ @collected_trust_refusals[plugin_id] =
155
+ existing ? existing.merge(count: existing[:count] + record[:count]) : record
156
+ end
157
+ end
158
+
122
159
  # ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`. Editor mode (`buffer:` non-nil)
123
160
  # silently overrides pool mode to sequential: per design § "Ractor pool mode", the pool's warm-up
124
161
  # cost dominates one-file wall time, so the pool gains nothing on a per-buffer invocation. The
@@ -146,10 +183,8 @@ module Rigor
146
183
  # (`expansion.fetch(:files)`), independent of any `analyze_only` narrowing of `files`; see
147
184
  # {#serve_empty_closure} for what an empty `files` does with it.
148
185
  def analyze_files(files, environment: nil, project_files: nil)
149
- if files.empty?
150
- serve_empty_closure(environment, project_files)
151
- return []
152
- end
186
+ return serve_empty_closure(environment, project_files) if files.empty?
187
+
153
188
  # Issue #784 / #793 — `files` is what this run ANALYSES; `source_files` is what its environment
154
189
  # is BUILT over, and the two are the same only on a full run. A narrowed run (`analyze_only`: an
155
190
  # incremental closure, a `--verify-incremental` partition) used to build its environment over the
@@ -199,7 +234,16 @@ module Rigor
199
234
  # whether anyone could have demanded anything at all: with no files, nobody could have, and an
200
235
  # empty project keeps paying no env build. Keyed on the project's files rather than on
201
236
  # `analyze_only`, because a recheck over an EMPTY project narrows to `Set[]`, which is non-nil.
237
+ #
238
+ # Issue #1541 — and, in pool mode, its `prepare` rows. A pooled run's `#prepare` happens inside the
239
+ # workers and its rows reach the stream through their first payload, so the coordinator-side
240
+ # pre-pass skips it ({ProjectPrePasses#run}) and {DiagnosticAggregator#pre_file_diagnostics} omits
241
+ # the cached rows; with no worker dispatched the row appeared nowhere, and a plugin whose `#prepare`
242
+ # raises turned a red project green on a nothing-changed `--incremental --workers N` recheck. They
243
+ # are returned as the run's per-file stream head, where the pool path puts them (`.rigor.yml`).
244
+ # Sequential, whose pre-pass already ran `#prepare`, returns no rows here.
202
245
  def serve_empty_closure(environment, project_files)
246
+ prepare_rows = pool_mode? ? pooled_prepare_rows : []
203
247
  env = environment || @environment_override
204
248
  # Resolved over the project's OWN file list — not `[]`, which would drop every plugin-synthesized
205
249
  # virtual RBS (`Environment.collect_virtual_rbs` short-circuits on an empty list) and scan a
@@ -217,9 +261,17 @@ module Rigor
217
261
  # `env` rather than `resolve.call`: read the demand the snapshots above just made when they built
218
262
  # an environment, and force nothing when they did not.
219
263
  record_definition_build_failures(env&.rbs_loader&.definition_build_failures)
220
- return if replayed_restored_run_level_rows?
264
+ record_hkt_scan_failure(hkt_scan_outcome(resolve.call)) unless replayed_restored_run_level_rows?
265
+ prepare_rows
266
+ end
221
267
 
222
- record_hkt_scan_failure(hkt_scan_outcome(resolve.call))
268
+ # Issue #1541 — the pooled empty closure's `#prepare`: the rows go to the caller, the run-scoped
269
+ # registrations into the table the workers' payloads would have filled.
270
+ def pooled_prepare_rows
271
+ rows, records, refusals = @plugin_prepare.call
272
+ merge_run_disclosures(records)
273
+ merge_trust_refusals(refusals)
274
+ Array(rows)
223
275
  end
224
276
 
225
277
  # Issue #794 — the signature-state snapshot's own gates, asked BEFORE an environment exists. Every
@@ -621,13 +673,15 @@ module Rigor
621
673
  required_features: features
622
674
  )
623
675
  main = Ractor.main
624
- main.send([:prepare, session.prepare_diagnostics])
676
+ # Issue #1900 — each message carries the refusals made since the previous one, so the coordinator
677
+ # can count ONE worker's `#prepare` (every worker repeats it) and sum the files in file order.
678
+ main.send([:prepare, session.prepare_diagnostics, session.drain_trust_refusals])
625
679
 
626
680
  loop do
627
681
  msg = Ractor.receive
628
682
  break if msg.nil?
629
683
 
630
- main.send([:file, msg, session.analyze(msg)])
684
+ main.send([:file, msg, session.analyze(msg), session.drain_trust_refusals])
631
685
  end
632
686
 
633
687
  # Issue #1051 — sent with `:done`, not with `:prepare`: this backend builds no parent session,
@@ -646,6 +700,8 @@ module Rigor
646
700
  pool.each { |worker| worker.send(nil) }
647
701
 
648
702
  prepare_diagnostics = nil
703
+ prepare_refusals = nil
704
+ refusals_by_path = {}
649
705
  results_by_path = {}
650
706
  terminated = 0
651
707
 
@@ -663,9 +719,11 @@ module Rigor
663
719
 
664
720
  case message.first
665
721
  when :prepare
666
- prepare_diagnostics ||= message.last
722
+ prepare_diagnostics ||= message[1]
723
+ prepare_refusals ||= message[2]
667
724
  when :file
668
725
  results_by_path[message[1]] = message[2]
726
+ refusals_by_path[message[1]] = message[3]
669
727
  when :done
670
728
  merge_worker_reporters(message[1])
671
729
  merge_run_disclosures(message[2])
@@ -684,6 +742,8 @@ module Rigor
684
742
  # it did. Re-analysed in process, exactly as the fork backend re-analyses a dead child's slice.
685
743
  degraded = files.reject { |path| results_by_path.key?(path) }
686
744
  reanalyze_degraded_in_process(degraded, results_by_path, source_files: source_files)
745
+ merge_trust_refusals(prepare_refusals)
746
+ files.each { |path| merge_trust_refusals(refusals_by_path[path]) }
687
747
 
688
748
  diagnostics = Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
689
749
  degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "ractor"))
@@ -700,8 +760,9 @@ module Rigor
700
760
  # Issue #1051, known exception: run-scoped disclosures a plugin registered from `#prepare` are NOT
701
761
  # recovered here — nor, since #1060, a positioned `#emit_once` batch registered there, which rides
702
762
  # the same table. There is no session to drain — this backend builds none on the coordinator — and
703
- # `#prepare` deliberately does not run on the coordinator-side registry under pool mode, so a
704
- # prepare-time disclosure lives only inside the Ractor that died with it. A disclosure registered
763
+ # `#prepare` does not run on the coordinator-side registry under pool mode (only an empty closure
764
+ # runs it, on a throwaway registry; see {#serve_empty_closure}), so a prepare-time disclosure lives
765
+ # only inside the Ractor that died with it. A disclosure registered
705
766
  # from `#diagnostics_for_file` IS recovered, because the re-analysis below runs the coordinator-side
706
767
  # plugin instances that {DiagnosticAggregator#plugin_run_disclosure_diagnostics} reads directly.
707
768
  # Left as is: recovering the rest means running `#prepare` on the coordinator registry, which would
@@ -776,23 +837,34 @@ module Rigor
776
837
  # `merge_run_disclosures` drops it as a duplicate; only a disclosure a worker registers while
777
838
  # analysing its own slice adds anything.
778
839
  merge_run_disclosures(session.drain_run_disclosures)
840
+ # Issue #1900 — the parent's `#prepare` refusals, drained BEFORE the fork: every child inherits the
841
+ # session's record of what it already reported and drains only its own slice's, so the prepare-time
842
+ # refusal is counted once, as `--workers 0` counts it, rather than once per worker.
843
+ merge_trust_refusals(session.drain_trust_refusals)
779
844
  snapshot_fork_pool_stats(session) if @collect_stats
780
845
 
781
846
  worker_count = [@workers, files.size].min
782
847
  slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
783
848
  results_by_path = {}
849
+ # Issue #1900 — each surviving child's refusal drain by slice index, and each re-analysed file's by
850
+ # path, merged below in file order so the first refused path is the one `--workers 0` names.
851
+ refusals_by_slice = {}
852
+ refusals_by_path = {}
784
853
 
785
854
  degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
786
855
  children = slices.each_with_index.map do |slice, index|
787
856
  out_path = File.join(tmpdir, "worker-#{index}")
788
857
  { pid: fork { run_fork_worker(session, slice, out_path) },
789
- slice: slice, out_path: out_path }
858
+ slice: slice, out_path: out_path, index: index }
790
859
  end
791
- collect_fork_results(children, results_by_path)
860
+ collect_fork_results(children, results_by_path, refusals_by_slice: refusals_by_slice)
792
861
  end
793
862
 
794
863
  unless degraded.empty?
795
- degraded.each { |path| results_by_path[path] = session.analyze(path) }
864
+ degraded.each do |path|
865
+ results_by_path[path] = session.analyze(path)
866
+ refusals_by_path[path] = session.drain_trust_refusals
867
+ end
796
868
  merge_worker_reporters(session.drain_reporters)
797
869
  # The degraded slice ran on the parent session, so a disclosure it registered lives there.
798
870
  merge_run_disclosures(session.drain_run_disclosures)
@@ -802,11 +874,25 @@ module Rigor
802
874
  @collected_effects.merge!(session.drain_effects) if @record_effects
803
875
  end
804
876
 
877
+ merge_slice_refusals(slices, refusals_by_slice, refusals_by_path)
805
878
  diagnostics = Array(session.prepare_diagnostics) +
806
879
  files.flat_map { |path| results_by_path.fetch(path, []) }
807
880
  degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "fork"))
808
881
  end
809
882
 
883
+ # Issue #1900 — a surviving child's drain covers its whole slice; a degraded slice was re-analysed on
884
+ # the parent and drained file by file. Slices are contiguous runs of `files`, so walking them in index
885
+ # order merges every refusal in the order the sequential run made it.
886
+ def merge_slice_refusals(slices, refusals_by_slice, refusals_by_path)
887
+ slices.each_with_index do |slice, index|
888
+ if refusals_by_slice.key?(index)
889
+ merge_trust_refusals(refusals_by_slice[index])
890
+ else
891
+ slice.each { |path| merge_trust_refusals(refusals_by_path[path]) }
892
+ end
893
+ end
894
+ end
895
+
810
896
  # Child-process body for {#analyze_files_in_fork_pool}. Analyses the slice with the
811
897
  # copy-on-write-inherited session and writes the Marshal'd payload to `out_path`. `exit!` skips
812
898
  # `at_exit` / stdio flush — the payload is already durable on disk by then.
@@ -821,7 +907,8 @@ module Rigor
821
907
  results = slice.to_h { |path| [path, session.analyze(path)] }
822
908
  payload = { results: results, reporters: session.drain_reporters,
823
909
  dependencies: session.drain_dependencies,
824
- disclosures: session.drain_run_disclosures }
910
+ disclosures: session.drain_run_disclosures,
911
+ refusals: session.drain_trust_refusals }
825
912
  # ADR-103 WD13 — the effects slot exists only on a collecting run, so a normal `--workers N` run
826
913
  # marshals exactly the payload it marshalled before the feature existed.
827
914
  payload[:effects] = session.drain_effects if @record_effects
@@ -845,7 +932,9 @@ module Rigor
845
932
 
846
933
  # Waits for every forked child, merges each successful payload into `results_by_path`, and returns
847
934
  # the file paths whose worker exited abnormally (for in-process degrade).
848
- def collect_fork_results(children, results_by_path)
935
+ # @param refusals_by_slice — issue #1900 — receives each successful payload's trust-refusal drain
936
+ # under the child's slice index, for the caller to merge in file order; nil merges it at once.
937
+ def collect_fork_results(children, results_by_path, refusals_by_slice: nil)
849
938
  degraded = []
850
939
  children.each do |child|
851
940
  _, status = Process.waitpid2(child[:pid])
@@ -854,6 +943,8 @@ module Rigor
854
943
  results_by_path.merge!(payload.fetch(:results))
855
944
  merge_worker_reporters(payload.fetch(:reporters))
856
945
  merge_run_disclosures(payload.fetch(:disclosures, []))
946
+ refusals = payload.fetch(:refusals, [])
947
+ refusals_by_slice ? refusals_by_slice[child[:index]] = refusals : merge_trust_refusals(refusals)
857
948
  @collected_dependencies.merge!(payload.fetch(:dependencies, {})) if @record_dependencies
858
949
  @collected_effects.merge!(payload.fetch(:effects, {})) if @record_effects
859
950
  else
@@ -938,7 +1029,15 @@ module Rigor
938
1029
  # @param source_files — issue #793 — the whole project, so this path's environment
939
1030
  # carries the same plugin-synthesized RBS the pool workers' would. It used to build over `[]`,
940
1031
  # i.e. with no synthesized RBS at all, even on a full run.
1032
+ #
1033
+ # Issue #1900 — it also runs `#prepare` on the coordinator-side registry it analyses with, BEFORE the
1034
+ # environment build as the sequential pre-pass does. A pooled run's pre-pass skips `#prepare` (the
1035
+ # workers run it), and this path has no workers, so every plugin used to analyse unprepared and its
1036
+ # prepare rows and prepare-time trust refusals were lost. The rows head the stream, where the pool
1037
+ # backends put their workers' prepare rows; the refusals land on the coordinator's own
1038
+ # {Plugin::IoBoundary}s, which {DiagnosticAggregator#plugin_trust_refusal_diagnostics} reads.
941
1039
  def analyze_files_sequentially_fallback(files, reason:, source_files: files)
1040
+ prepare_rows = prepare_coordinator_plugins_once
942
1041
  environment = build_runner_environment(source_files: source_files)
943
1042
  # Same set, same ORDER as `#analyze_files_sequentially`. The signature state used to be hand-copied
944
1043
  # here slot by slot, which dropped `synthesized-namespace` and every conformance row. The conformance
@@ -946,7 +1045,7 @@ module Rigor
946
1045
  # BEFORE the definition-build failures are read below, or a failure only that scan reaches is lost.
947
1046
  snapshot_project_signature_state(environment)
948
1047
  snapshot_effect_annotation_carrier(environment.rbs_loader)
949
- diagnostics = files.flat_map { |path| @analyze_file.call(path, environment) }
1048
+ diagnostics = prepare_rows + files.flat_map { |path| @analyze_file.call(path, environment) }
950
1049
  loader = environment.rbs_loader
951
1050
  # Issue #696 — this path analyses on the coordinator, so its loader IS the one that reached the
952
1051
  # failing build, and it must snapshot the same slot the two real paths do. Reached whenever
@@ -968,6 +1067,15 @@ module Rigor
968
1067
  )
969
1068
  end
970
1069
 
1070
+ # Issue #1900 — `#prepare` on the coordinator-side registry, at most once per run: a second call would
1071
+ # re-publish every cross-plugin fact into the shared fact store.
1072
+ def prepare_coordinator_plugins_once
1073
+ return [] if @coordinator_plugins_prepared
1074
+
1075
+ @coordinator_plugins_prepared = true
1076
+ Array(@prepare_coordinator_plugins.call)
1077
+ end
1078
+
971
1079
  def merge_worker_reporters(drained)
972
1080
  merge_rbs_extended_reporter(drained.fetch(:rbs_extended))
973
1081
  drained.fetch(:boundary_cross).each do |entry|
@@ -1062,8 +1170,14 @@ module Rigor
1062
1170
  def record_definition_build_failures(failures)
1063
1171
  return if failures.nil? || failures.empty?
1064
1172
 
1173
+ # Issue #1902 — one tuple per class, chosen by {Environment::RbsLoader.definition_build_detail_order}
1174
+ # rather than by arrival: a fresh record and a replayed one (or two workers' records) for the same
1175
+ # class can name different failures, and the earlier one used to win.
1065
1176
  @snapshots.definition_build_failures =
1066
- (@snapshots.definition_build_failures + failures).uniq(&:first).freeze
1177
+ (@snapshots.definition_build_failures + failures)
1178
+ .group_by(&:first)
1179
+ .map { |_, candidates| candidates.min_by { |c| Environment::RbsLoader.definition_build_detail_order(c) } }
1180
+ .freeze
1067
1181
  end
1068
1182
 
1069
1183
  # Issue #784 — first-wins, unlike {#record_definition_build_failures}'s accumulate-and-dedup. That