rigortype 0.1.16 → 0.1.18
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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
- data/lib/rigor/analysis/check_rules.rb +180 -73
- data/lib/rigor/analysis/dependency_recorder.rb +122 -0
- data/lib/rigor/analysis/diagnostic.rb +18 -0
- data/lib/rigor/analysis/incremental.rb +162 -0
- data/lib/rigor/analysis/incremental_session.rb +337 -0
- data/lib/rigor/analysis/rule_catalog.rb +48 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +477 -1110
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/builtins/static_return_refinements.rb +7 -1
- data/lib/rigor/cache/descriptor.rb +50 -49
- data/lib/rigor/cache/incremental_snapshot.rb +153 -0
- data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
- data/lib/rigor/cache/rbs_constant_table.rb +2 -8
- data/lib/rigor/cache/rbs_environment.rb +2 -8
- data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
- data/lib/rigor/cache/store.rb +145 -14
- data/lib/rigor/cli/annotate_command.rb +2 -7
- data/lib/rigor/cli/baseline_command.rb +2 -7
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/command.rb +47 -0
- data/lib/rigor/cli/coverage_command.rb +3 -23
- data/lib/rigor/cli/coverage_renderer.rb +3 -8
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/diff_command.rb +3 -7
- data/lib/rigor/cli/explain_command.rb +2 -7
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mcp_command.rb +3 -7
- data/lib/rigor/cli/options.rb +57 -0
- data/lib/rigor/cli/plugin_command.rb +3 -7
- data/lib/rigor/cli/plugins_command.rb +2 -7
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/renderable.rb +26 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -7
- data/lib/rigor/cli/skill_command.rb +3 -7
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +2 -7
- data/lib/rigor/cli/type_of_command.rb +5 -38
- data/lib/rigor/cli/type_of_renderer.rb +4 -9
- data/lib/rigor/cli/type_scan_command.rb +3 -23
- data/lib/rigor/cli/type_scan_renderer.rb +4 -9
- data/lib/rigor/cli.rb +15 -532
- data/lib/rigor/configuration/dependencies.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +22 -3
- data/lib/rigor/configuration.rb +16 -3
- data/lib/rigor/environment/rbs_loader.rb +129 -71
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/block_parameter_binder.rb +1 -2
- data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
- data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
- data/lib/rigor/inference/expression_typer.rb +149 -63
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
- data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher.rb +185 -84
- data/lib/rigor/inference/narrowing.rb +262 -5
- data/lib/rigor/inference/scope_indexer.rb +208 -21
- data/lib/rigor/inference/statement_evaluator.rb +110 -48
- data/lib/rigor/language_server/buffer_resolution.rb +33 -0
- data/lib/rigor/language_server/completion_provider.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
- data/lib/rigor/language_server/folding_range_provider.rb +4 -4
- data/lib/rigor/language_server/hover_provider.rb +4 -4
- data/lib/rigor/language_server/selection_range_provider.rb +4 -4
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +302 -45
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +281 -15
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
- data/lib/rigor/rbs_extended.rb +39 -0
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +150 -167
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/acceptance_router.rb +19 -0
- data/lib/rigor/type/accepts_result.rb +3 -10
- data/lib/rigor/type/app.rb +3 -7
- data/lib/rigor/type/bot.rb +2 -3
- data/lib/rigor/type/bound_method.rb +5 -12
- data/lib/rigor/type/combinator.rb +22 -0
- data/lib/rigor/type/constant.rb +2 -3
- data/lib/rigor/type/data_class.rb +80 -0
- data/lib/rigor/type/data_instance.rb +100 -0
- data/lib/rigor/type/difference.rb +5 -10
- data/lib/rigor/type/dynamic.rb +5 -10
- data/lib/rigor/type/hash_shape.rb +5 -15
- data/lib/rigor/type/integer_range.rb +5 -10
- data/lib/rigor/type/intersection.rb +5 -10
- data/lib/rigor/type/nominal.rb +5 -10
- data/lib/rigor/type/refined.rb +5 -10
- data/lib/rigor/type/singleton.rb +5 -10
- data/lib/rigor/type/top.rb +2 -3
- data/lib/rigor/type/tuple.rb +5 -10
- data/lib/rigor/type/union.rb +5 -10
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/value_semantics.rb +77 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
- data/sig/rigor/cache.rbs +19 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +27 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +42 -25
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +58 -1
- data/sig/rigor.rbs +6 -1
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +36 -2
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
|
@@ -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
|