rigortype 0.1.17 → 0.1.19

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -0,0 +1,569 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ require_relative "../../environment"
6
+ require_relative "../diagnostic"
7
+ require_relative "../worker_session"
8
+ require_relative "../run_stats"
9
+ require_relative "../../rbs_extended/conformance_checker"
10
+
11
+ module Rigor
12
+ module Analysis
13
+ class Runner
14
+ # Owns the per-file analysis dispatch: the sequential coordinator
15
+ # path (default) and both worker-pool backends — the ADR-15
16
+ # Phase 4b Ractor pool and the ADR-15 Amendment fork pool, plus the
17
+ # pool-degraded fallback. Builds the coordinator-side Environment,
18
+ # snapshots the end-of-pass RBS tables into the shared
19
+ # {RunSnapshots}, and merges per-worker reporter drains back into
20
+ # the runner's reporter accumulators.
21
+ #
22
+ # Sequential-equivalence contract (docs/internal-spec/
23
+ # worker-session.md): the pool path MUST produce the same
24
+ # diagnostics, in the same order, as the sequential path. The
25
+ # coordinator re-orders worker results by original path order and
26
+ # replays per-worker reporter entries through the dedupe-on-record
27
+ # APIs so reporter state matches a single-session run.
28
+ #
29
+ # Per-run varying prepass state (plugin registry, dependency-source
30
+ # index, synthetic-method / project-patched indexes) is read
31
+ # through injected reader procs; the actual per-file analysis is
32
+ # delegated back through an injected `analyze_file` callable so the
33
+ # CheckRules / recorder / plugin-emission machinery stays on the
34
+ # {Runner}.
35
+ class PoolCoordinator # rubocop:disable Metrics/ClassLength
36
+ # @param configuration [Rigor::Configuration]
37
+ # @param cache_store [Rigor::Cache::Store, nil]
38
+ # @param explain [Boolean]
39
+ # @param workers [Integer]
40
+ # @param collect_stats [Boolean]
41
+ # @param buffer [BufferBinding, nil]
42
+ # @param environment_override [Rigor::Environment, nil]
43
+ # @param rbs_extended_reporter [RbsExtended::Reporter]
44
+ # @param boundary_cross_reporter
45
+ # [DependencySourceInference::BoundaryCrossReporter]
46
+ # @param source_rbs_synthesis_reporter
47
+ # [Plugin::SourceRbsSynthesisReporter]
48
+ # @param snapshots [RunSnapshots] shared end-of-pass snapshot sink.
49
+ # @param plugin_registry [#call] reader for the current registry.
50
+ # @param dependency_source_index [#call] reader.
51
+ # @param synthetic_method_index [#call] reader.
52
+ # @param project_patched_methods [#call] reader.
53
+ # @param project_scope_seed [#call] reader for the cross-file
54
+ # pre-pass seed tables (`Runner#project_scope_seed_tables`).
55
+ # @param analyze_file [#call] `(path, environment) -> diagnostics`.
56
+ def initialize(configuration:, cache_store:, explain:, workers:, collect_stats:, # rubocop:disable Metrics/ParameterLists
57
+ buffer:, environment_override:, rbs_extended_reporter:,
58
+ boundary_cross_reporter:, source_rbs_synthesis_reporter:,
59
+ snapshots:, plugin_registry:, dependency_source_index:,
60
+ synthetic_method_index:, project_patched_methods:,
61
+ analyze_file:, project_scope_seed: -> { {} })
62
+ @configuration = configuration
63
+ @cache_store = cache_store
64
+ @explain = explain
65
+ @workers = workers
66
+ @collect_stats = collect_stats
67
+ @buffer = buffer
68
+ @environment_override = environment_override
69
+ @rbs_extended_reporter = rbs_extended_reporter
70
+ @boundary_cross_reporter = boundary_cross_reporter
71
+ @source_rbs_synthesis_reporter = source_rbs_synthesis_reporter
72
+ @snapshots = snapshots
73
+ @plugin_registry_reader = plugin_registry
74
+ @dependency_source_index_reader = dependency_source_index
75
+ @synthetic_method_index_reader = synthetic_method_index
76
+ @project_patched_methods_reader = project_patched_methods
77
+ @project_scope_seed_reader = project_scope_seed
78
+ @analyze_file = analyze_file
79
+ end
80
+
81
+ # ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`.
82
+ # Editor mode (`buffer:` non-nil) silently overrides pool
83
+ # mode to sequential: per design § "Ractor pool mode", the
84
+ # pool's warm-up cost dominates one-file wall time, so the
85
+ # pool gains nothing on a per-buffer invocation. The override
86
+ # is part of the contract — not a degradation diagnostic —
87
+ # because `--workers=N` is a project-scale knob and editor
88
+ # mode is per-buffer; the conflict resolves toward the more
89
+ # specific axis.
90
+ def pool_mode?
91
+ return false unless @workers.is_a?(Integer) && @workers.positive?
92
+
93
+ @buffer.nil?
94
+ end
95
+
96
+ # ADR-15 Phase 4b — routes per-file analysis to either the
97
+ # sequential coordinator-side Environment (legacy path,
98
+ # default) or a Ractor worker pool built around
99
+ # {WorkerSession} (opt-in via `workers:`). The sequential
100
+ # path is bit-for-bit unchanged from v0.1.4 / earlier; the
101
+ # pool path is the substrate exercised by phase 4c when
102
+ # `RIGOR_RACTOR_WORKERS` / `.rigor.yml` `parallel.workers:`
103
+ # is wired.
104
+ #
105
+ # Sequential mode also snapshots `class_decl_paths` from the
106
+ # local environment after the per-file loop completes so
107
+ # `RunStats` can attribute the RBS class universe between
108
+ # project-sig and bundled sources. The env stays a LOCAL
109
+ # variable (not an ivar) so it goes GC-eligible when the
110
+ # method returns — holding it as long-lived state added
111
+ # memory pressure that surfaced as a Bus Error during the
112
+ # spec suite under Ruby 4.0 + rbs 4.0.2.
113
+ def analyze_files(files, environment: nil)
114
+ return [] if files.empty?
115
+ return dispatch_pool(files) if pool_mode?
116
+
117
+ analyze_files_sequentially(files, environment || resolve_sequential_environment(source_files: files))
118
+ end
119
+
120
+ def analyze_files_sequentially(files, environment)
121
+ # Snapshot the small synthesized-namespace name list (NOT the
122
+ # env — see the method comment) so #run can surface the
123
+ # malformed-RBS `:info` diagnostic without rebuilding the env.
124
+ # Gated on the project actually declaring `signature_paths:`:
125
+ # synthesis only matters for the project's own RBS, and
126
+ # `#synthesized_namespaces` forces the (otherwise-lazy) RBS env
127
+ # to build — doing so when there is no project sig set would
128
+ # warm `.rigor/cache` on a bare `--no-stats` run.
129
+ @snapshots.synthesized_namespaces =
130
+ project_signature_paths? ? (environment.rbs_loader&.synthesized_namespaces || []) : []
131
+ # `rigor:v1:conforms-to` lives only in the project's own
132
+ # `signature_paths:` RBS, so gate the scan the same way and
133
+ # reuse the already-built env (no extra RBS load).
134
+ @snapshots.conformance_results =
135
+ project_signature_paths? ? RbsExtended::ConformanceChecker.scan(environment.rbs_loader) : []
136
+ result = files.flat_map { |path| @analyze_file.call(path, environment) }
137
+ if @collect_stats
138
+ loader = environment.rbs_loader
139
+ @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
140
+ @snapshots.signature_paths = loader&.signature_paths || [].freeze
141
+ end
142
+ result
143
+ end
144
+
145
+ # Sequential-mode environment resolver. Returns the supplied
146
+ # `environment:` override (with the runner's fresh per-run
147
+ # reporter pair attached so dispatcher events route to THIS
148
+ # runner's diagnostics) when present; otherwise builds a
149
+ # fresh Environment per-call via {#build_runner_environment}
150
+ # — preserving the pre-override behaviour bit-for-bit.
151
+ def resolve_sequential_environment(source_files: [])
152
+ return build_runner_environment(source_files: source_files) unless @environment_override
153
+
154
+ @environment_override.attach_reporters!(
155
+ rbs_extended_reporter: @rbs_extended_reporter,
156
+ boundary_cross_reporter: @boundary_cross_reporter
157
+ )
158
+ @environment_override
159
+ end
160
+
161
+ # ADR-15 Amendment (2026-05-20) — worker-pool backend selector.
162
+ # `fork` is the active backend: separate processes sidestep both
163
+ # the Ruby Bug #22075 use-after-free and the worker-side
164
+ # `Ractor::IsolationError` that make the Ractor pool unusable
165
+ # (see the ADR-15 Amendment +
166
+ # docs/notes/20260520-ractor-pool-cruby-uaf.md). The Ractor pool
167
+ # is preserved but off the default path — `RIGOR_POOL_BACKEND=ractor`
168
+ # opts back in so it stays testable. Platforms without `fork`
169
+ # (Windows) fall back to sequential.
170
+ def pool_backend
171
+ return :ractor if ENV["RIGOR_POOL_BACKEND"] == "ractor"
172
+ return :fork if Process.respond_to?(:fork)
173
+
174
+ :sequential
175
+ end
176
+
177
+ # Routes pool-mode analysis to the selected backend.
178
+ def dispatch_pool(files)
179
+ case pool_backend
180
+ when :ractor then analyze_files_in_pool(files)
181
+ when :fork then analyze_files_in_fork_pool(files)
182
+ else
183
+ analyze_files_sequentially_fallback(
184
+ files, reason: "fork-based parallelism is unavailable on this platform"
185
+ )
186
+ end
187
+ end
188
+
189
+ # Coordinator-side Environment used by the sequential code
190
+ # path. Pool mode builds one Environment per worker inside
191
+ # the worker Ractor's body instead.
192
+ #
193
+ # ADR-32 WD4 — `source_files:` is threaded down so that
194
+ # `Environment.for_project` can invoke each loaded plugin's
195
+ # `source_rbs_synthesizer` callable per project source file
196
+ # at env-build time. Defaults to `[]` for callers that don't
197
+ # have a file list yet (e.g. pre-pass-only build paths); in
198
+ # that case no synthesised RBS is contributed.
199
+ def build_runner_environment(source_files: [])
200
+ Environment.for_project(
201
+ libraries: @configuration.libraries,
202
+ signature_paths: @configuration.signature_paths,
203
+ cache_store: @cache_store,
204
+ plugin_registry: plugin_registry,
205
+ dependency_source_index: dependency_source_index,
206
+ rbs_extended_reporter: @rbs_extended_reporter,
207
+ boundary_cross_reporter: @boundary_cross_reporter,
208
+ source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
209
+ bundler_bundle_path: @configuration.bundler_bundle_path,
210
+ bundler_auto_detect: @configuration.bundler_auto_detect,
211
+ bundler_lockfile: @configuration.bundler_lockfile,
212
+ rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
213
+ rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
214
+ synthetic_method_index: synthetic_method_index,
215
+ project_patched_methods: project_patched_methods,
216
+ source_files: source_files
217
+ )
218
+ end
219
+
220
+ # ADR-15 Phase 4b — Ractor pool around {WorkerSession}.
221
+ # Spawns `@workers` Ractors; each takes the shareable
222
+ # payload (Configuration, cache_root String, plugin
223
+ # Blueprint Array, explain Boolean) and builds its OWN
224
+ # WorkerSession internally. Files are distributed
225
+ # round-robin across the pool; each worker writes back to
226
+ # the main Ractor's mailbox via `Ractor.main.send` with
227
+ # one of three message kinds:
228
+ #
229
+ # - `[:prepare, diagnostics]` — once at startup, the
230
+ # session's `prepare_diagnostics` snapshot. The
231
+ # coordinator keeps the FIRST worker's snapshot only
232
+ # (plugin `#prepare` is deterministic per plugin, so
233
+ # each worker produces the same diagnostic set; surfacing
234
+ # them once avoids N× duplication).
235
+ # - `[:file, path, diagnostics]` — one per analysed file.
236
+ # - `[:done, drained_reporters]` — once at exit, the
237
+ # per-worker reporter snapshots for end-of-pool merge.
238
+ #
239
+ # The Ruby 4.0+ Ractor model uses a single per-Ractor
240
+ # mailbox (no `Ractor.yield`); workers push back via
241
+ # `Ractor.main.send`. The coordinator drains its mailbox
242
+ # via `Ractor.receive` until it has counted exactly
243
+ # `pool.size` `:done` messages.
244
+ #
245
+ # Diagnostic order: original path order. Workers may
246
+ # complete files out of order; the coordinator re-orders
247
+ # via the `results_by_path` Hash before flattening.
248
+ #
249
+ # Reporter merge: per-worker `RbsExtended::Reporter` and
250
+ # `BoundaryCrossReporter` entries are replayed into the
251
+ # runner-side accumulators via their `record_*` APIs,
252
+ # which dedupe on the same keys as a single-session run
253
+ # would. Net result: reporter state is identical to the
254
+ # sequential path.
255
+ def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
256
+ # Pre-warm class-level lazy memos on the MAIN Ractor.
257
+ # `Environment::ClassRegistry.default` is the
258
+ # default kwarg threaded through `Environment.new`
259
+ # inside each worker session; lazy-initialising it
260
+ # from a non-main Ractor would trip
261
+ # `Ractor::IsolationError`. Touching it here forces
262
+ # the (shareable) registry into the class-ivar cache
263
+ # before any worker reads.
264
+ Environment::ClassRegistry.default
265
+
266
+ # ADR-15 Phase 4b.x — pre-warm the RBS cache so
267
+ # workers serve every reflection query from the
268
+ # Marshal blob on disk. Without this, the first
269
+ # cache MISS inside a worker falls through to
270
+ # `RBS::EnvironmentLoader.new`, which reads a chain
271
+ # of non-`Ractor.shareable?` RubyGems / RBS module
272
+ # constants and raises `Ractor::IsolationError`.
273
+ # Pre-warming requires a `cache_store`; the run aborts
274
+ # to sequential mode otherwise. See ADR-15 Phase 4b.x
275
+ # for the full chain of failing constants.
276
+ if @cache_store.nil?
277
+ return analyze_files_sequentially_fallback(
278
+ files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
279
+ )
280
+ end
281
+ prewarm_rbs_cache_for_pool
282
+
283
+ configuration = @configuration
284
+ cache_root = @cache_store&.root
285
+ blueprints = plugin_registry.blueprints
286
+ explain = @explain
287
+ # ADR-32 WD4 — the full project file list travels into
288
+ # every Ractor worker so each worker's WorkerSession
289
+ # can invoke loaded plugins' source_rbs_synthesizers at
290
+ # env-build time. The list is a frozen Array<String>;
291
+ # cheaply shareable.
292
+ shareable_source_files = files.map { |path| path.to_s.dup.freeze }.freeze
293
+
294
+ pool = Array.new(@workers) do
295
+ Ractor.new(configuration, cache_root, blueprints, explain, shareable_source_files) do |configuration, cache_root, blueprints, explain, shareable_source_files| # rubocop:disable Layout/LineLength
296
+ cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
297
+ session = Rigor::Analysis::WorkerSession.new(
298
+ configuration: configuration,
299
+ cache_store: cache_store,
300
+ plugin_blueprints: blueprints,
301
+ explain: explain,
302
+ source_files: shareable_source_files
303
+ )
304
+ main = Ractor.main
305
+ main.send([:prepare, session.prepare_diagnostics])
306
+
307
+ loop do
308
+ msg = Ractor.receive
309
+ break if msg.nil?
310
+
311
+ main.send([:file, msg, session.analyze(msg)])
312
+ end
313
+
314
+ main.send([:done, session.drain_reporters])
315
+ end
316
+ end
317
+
318
+ files.each_with_index { |path, index| pool[index % pool.size].send(path) }
319
+ pool.each { |worker| worker.send(nil) }
320
+
321
+ prepare_diagnostics = nil
322
+ results_by_path = {}
323
+ done_count = 0
324
+
325
+ while done_count < pool.size
326
+ message = Ractor.receive
327
+ case message.first
328
+ when :prepare
329
+ prepare_diagnostics ||= message.last
330
+ when :file
331
+ results_by_path[message[1]] = message[2]
332
+ when :done
333
+ merge_worker_reporters(message.last)
334
+ done_count += 1
335
+ end
336
+ end
337
+
338
+ pool.each(&:join)
339
+
340
+ Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
341
+ end
342
+
343
+ # ADR-15 Amendment (2026-05-20) — fork-based worker pool, the
344
+ # active backend for `workers > 0`. Builds ONE {WorkerSession}
345
+ # on the parent, then `fork`s N children that copy-on-write
346
+ # inherit it. Each child analyses a contiguous slice of `files`
347
+ # and writes a Marshal'd `{results:, reporters:}` payload to a
348
+ # temp file; the parent `Process.wait`s every child, merges the
349
+ # payloads, and re-orders diagnostics by original path order.
350
+ #
351
+ # Separate processes have separate GC heaps and `vm->ci_table`
352
+ # (immune to Ruby Bug #22075) and copy-on-write-inherit every
353
+ # constant (no `Ractor.shareable?` constraint). See the ADR-15
354
+ # Amendment + docs/notes/20260520-ractor-pool-cruby-uaf.md.
355
+ #
356
+ # A child that exits non-zero (crash / unmarshalable payload) is
357
+ # degraded: the parent re-analyses that slice in-process and
358
+ # prepends a `pool-degraded` warning.
359
+ def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
360
+ Environment::ClassRegistry.default
361
+
362
+ session = WorkerSession.new(
363
+ configuration: @configuration,
364
+ cache_store: @cache_store,
365
+ plugin_blueprints: plugin_registry.blueprints,
366
+ explain: @explain,
367
+ synthetic_method_index: synthetic_method_index,
368
+ project_patched_methods: project_patched_methods,
369
+ project_scope_seed: project_scope_seed,
370
+ source_files: files
371
+ )
372
+ # Force the full RBS load on the parent so children
373
+ # copy-on-write inherit a warm Environment rather than each
374
+ # rebuilding it after the fork.
375
+ session.environment.rbs_loader&.prewarm
376
+ snapshot_fork_pool_stats(session) if @collect_stats
377
+
378
+ worker_count = [@workers, files.size].min
379
+ slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
380
+ results_by_path = {}
381
+
382
+ degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
383
+ children = slices.each_with_index.map do |slice, index|
384
+ out_path = File.join(tmpdir, "worker-#{index}")
385
+ { pid: fork { run_fork_worker(session, slice, out_path) },
386
+ slice: slice, out_path: out_path }
387
+ end
388
+ collect_fork_results(children, results_by_path)
389
+ end
390
+
391
+ unless degraded.empty?
392
+ degraded.each { |path| results_by_path[path] = session.analyze(path) }
393
+ merge_worker_reporters(session.drain_reporters)
394
+ end
395
+
396
+ diagnostics = Array(session.prepare_diagnostics) +
397
+ files.flat_map { |path| results_by_path.fetch(path, []) }
398
+ degraded.empty? ? diagnostics : diagnostics.unshift(fork_degraded_diagnostic(degraded.size))
399
+ end
400
+
401
+ # Child-process body for {#analyze_files_in_fork_pool}. Analyses
402
+ # the slice with the copy-on-write-inherited session and writes
403
+ # the Marshal'd payload to `out_path`. `exit!` skips `at_exit` /
404
+ # stdio flush — the payload is already durable on disk by then.
405
+ def run_fork_worker(session, slice, out_path)
406
+ results = slice.to_h { |path| [path, session.analyze(path)] }
407
+ payload = { results: results, reporters: session.drain_reporters }
408
+ File.binwrite(out_path, Marshal.dump(payload))
409
+ exit!(0)
410
+ rescue StandardError
411
+ exit!(1)
412
+ end
413
+
414
+ # Snapshots `class_decl_paths` from the parent session's loader
415
+ # so end-of-run {RunStats} can attribute the RBS class universe.
416
+ def snapshot_fork_pool_stats(session)
417
+ loader = session.environment.rbs_loader
418
+ @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
419
+ @snapshots.signature_paths = loader&.signature_paths || [].freeze
420
+ end
421
+
422
+ # Waits for every forked child, merges each successful payload
423
+ # into `results_by_path`, and returns the file paths whose
424
+ # worker exited abnormally (for in-process degrade).
425
+ def collect_fork_results(children, results_by_path)
426
+ degraded = []
427
+ children.each do |child|
428
+ _, status = Process.waitpid2(child[:pid])
429
+ payload = fork_worker_payload(status, child[:out_path])
430
+ if payload
431
+ results_by_path.merge!(payload.fetch(:results))
432
+ merge_worker_reporters(payload.fetch(:reporters))
433
+ else
434
+ degraded.concat(child[:slice])
435
+ end
436
+ end
437
+ degraded
438
+ end
439
+
440
+ # @return [Hash, nil] the child's `{results:, reporters:}`
441
+ # payload, or nil when the child exited abnormally or wrote no
442
+ # readable payload. `Marshal.load` is safe here: the blob was
443
+ # written by our own forked child to a temp file we created.
444
+ def fork_worker_payload(status, out_path)
445
+ return nil unless status.success? && File.exist?(out_path)
446
+
447
+ Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
448
+ rescue StandardError
449
+ nil
450
+ end
451
+
452
+ def fork_degraded_diagnostic(count)
453
+ Diagnostic.new(
454
+ path: ".rigor.yml", line: 1, column: 1,
455
+ message: "fork pool degraded: #{count} file(s) re-analysed in-process " \
456
+ "after a worker exited abnormally",
457
+ severity: :warning, rule: "pool-degraded", source_family: :builtin
458
+ )
459
+ end
460
+
461
+ # ADR-15 Phase 4b.x — drives every cached RBS producer
462
+ # on the main Ractor so each worker can serve all
463
+ # reflection queries from disk (Marshal-load only).
464
+ # Builds a single coordinator-side {Environment} for
465
+ # this purpose; the env object is discarded immediately
466
+ # after the cache is warm — workers build their own
467
+ # `Environment.for_project` inside the Ractor body,
468
+ # which then routes through `cached_env` instead of
469
+ # `RBS::EnvironmentLoader.new`.
470
+ def prewarm_rbs_cache_for_pool
471
+ warm_env = Environment.for_project(
472
+ libraries: @configuration.libraries,
473
+ signature_paths: @configuration.signature_paths,
474
+ cache_store: @cache_store,
475
+ bundler_bundle_path: @configuration.bundler_bundle_path,
476
+ bundler_auto_detect: @configuration.bundler_auto_detect,
477
+ bundler_lockfile: @configuration.bundler_lockfile,
478
+ rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
479
+ rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
480
+ )
481
+ warm_env.rbs_loader&.prewarm
482
+ end
483
+
484
+ # ADR-15 Phase 4b.x — pool-mode safety net. When pool
485
+ # mode is configured but a precondition fails (currently:
486
+ # `--no-cache` would force workers through
487
+ # `EnvironmentLoader.new`), degrade to sequential
488
+ # analysis with a `:warning` `pool-degraded` diagnostic
489
+ # at run start. The actual per-file analysis runs on
490
+ # the coordinator, identical to the default sequential
491
+ # path.
492
+ def analyze_files_sequentially_fallback(files, reason:)
493
+ environment = build_runner_environment
494
+ diagnostics = files.flat_map { |path| @analyze_file.call(path, environment) }
495
+ loader = environment.rbs_loader
496
+ @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
497
+ @snapshots.signature_paths = loader&.signature_paths || [].freeze
498
+ diagnostics.unshift(
499
+ Diagnostic.new(
500
+ path: ".rigor.yml", line: 1, column: 1,
501
+ message: "pool mode degraded to sequential: #{reason}",
502
+ severity: :warning, rule: "pool-degraded", source_family: :builtin
503
+ )
504
+ )
505
+ end
506
+
507
+ def merge_worker_reporters(drained)
508
+ rbs = drained.fetch(:rbs_extended)
509
+ rbs.fetch(:unresolved_payloads).each do |entry|
510
+ @rbs_extended_reporter.record_unresolved(
511
+ payload: entry.payload, source_location: entry.source_location
512
+ )
513
+ end
514
+ rbs.fetch(:lossy_projections).each do |entry|
515
+ @rbs_extended_reporter.record_lossy_projection(
516
+ head: entry.head, source_location: entry.source_location
517
+ )
518
+ end
519
+ drained.fetch(:boundary_cross).each do |entry|
520
+ @boundary_cross_reporter.record(
521
+ class_name: entry.class_name,
522
+ method_name: entry.method_name,
523
+ gem_name: entry.gem_name,
524
+ rbs_display: entry.rbs_display
525
+ )
526
+ end
527
+ # ADR-32 WD6 — merge per-worker synthesizer failures
528
+ # back into the coordinator's reporter. Fetched with a
529
+ # default empty array so older drains (pre-slice-2)
530
+ # remain compatible.
531
+ Array(drained[:source_rbs_synthesis]).each do |entry|
532
+ @source_rbs_synthesis_reporter.record(
533
+ plugin_id: entry.plugin_id, path: entry.path, message: entry.message
534
+ )
535
+ end
536
+ end
537
+
538
+ private
539
+
540
+ # True when the project declares its own `signature_paths:` (the
541
+ # only place the qualified-name-without-namespace mistake lives).
542
+ def project_signature_paths?
543
+ paths = @configuration.signature_paths
544
+ !(paths.nil? || paths.empty?)
545
+ end
546
+
547
+ def plugin_registry
548
+ @plugin_registry_reader.call
549
+ end
550
+
551
+ def dependency_source_index
552
+ @dependency_source_index_reader.call
553
+ end
554
+
555
+ def synthetic_method_index
556
+ @synthetic_method_index_reader.call
557
+ end
558
+
559
+ def project_patched_methods
560
+ @project_patched_methods_reader.call
561
+ end
562
+
563
+ def project_scope_seed
564
+ @project_scope_seed_reader.call
565
+ end
566
+ end
567
+ end
568
+ end
569
+ end