rigortype 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +154 -33
- data/lib/rigor/analysis/check_rules.rb +10 -18
- data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
- data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
- data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
- data/lib/rigor/analysis/diagnostic.rb +0 -2
- data/lib/rigor/analysis/fact_store.rb +26 -6
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/rule_catalog.rb +2 -2
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +498 -12
- data/lib/rigor/analysis/worker_session.rb +327 -0
- data/lib/rigor/builtins/imported_refinements.rb +364 -55
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/cache/descriptor.rb +1 -1
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +39 -6
- data/lib/rigor/cli/diff_command.rb +1 -1
- data/lib/rigor/cli/sig_gen_command.rb +173 -0
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_renderer.rb +1 -1
- data/lib/rigor/cli/type_scan_report.rb +2 -2
- data/lib/rigor/cli.rb +61 -3
- data/lib/rigor/configuration/dependencies.rb +2 -2
- data/lib/rigor/configuration.rb +131 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/lockfile_resolver.rb +125 -0
- data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
- data/lib/rigor/environment/rbs_loader.rb +194 -6
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment.rb +109 -6
- data/lib/rigor/flow_contribution/conflict.rb +2 -2
- data/lib/rigor/flow_contribution/element.rb +1 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution/merge_result.rb +1 -1
- data/lib/rigor/flow_contribution/merger.rb +3 -3
- data/lib/rigor/flow_contribution.rb +2 -2
- data/lib/rigor/inference/acceptance.rb +35 -1
- data/lib/rigor/inference/block_parameter_binder.rb +0 -2
- data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
- data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
- data/lib/rigor/inference/coverage_scanner.rb +1 -1
- data/lib/rigor/inference/expression_typer.rb +77 -11
- data/lib/rigor/inference/fallback.rb +1 -1
- data/lib/rigor/inference/macro_block_self_type.rb +96 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -41
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +135 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +7 -12
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -44
- data/lib/rigor/inference/method_dispatcher.rb +274 -5
- data/lib/rigor/inference/method_parameter_binder.rb +22 -14
- data/lib/rigor/inference/narrowing.rb +129 -12
- data/lib/rigor/inference/rbs_type_translator.rb +0 -2
- data/lib/rigor/inference/scope_indexer.rb +14 -9
- data/lib/rigor/inference/statement_evaluator.rb +7 -7
- data/lib/rigor/inference/synthetic_method.rb +86 -0
- data/lib/rigor/inference/synthetic_method_index.rb +82 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
- data/lib/rigor/plugin/blueprint.rb +60 -0
- data/lib/rigor/plugin/io_boundary.rb +0 -2
- data/lib/rigor/plugin/loader.rb +5 -3
- data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
- data/lib/rigor/plugin/macro/external_file.rb +143 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
- data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
- data/lib/rigor/plugin/macro.rb +31 -0
- data/lib/rigor/plugin/manifest.rb +102 -10
- data/lib/rigor/plugin/registry.rb +43 -2
- data/lib/rigor/plugin/services.rb +1 -1
- data/lib/rigor/plugin/type_node_resolver.rb +52 -0
- data/lib/rigor/plugin.rb +2 -0
- data/lib/rigor/rbs_extended/reporter.rb +91 -0
- data/lib/rigor/rbs_extended.rb +131 -32
- data/lib/rigor/scope.rb +25 -8
- data/lib/rigor/sig_gen/classification.rb +36 -0
- data/lib/rigor/sig_gen/generator.rb +1048 -0
- data/lib/rigor/sig_gen/layout_index.rb +108 -0
- data/lib/rigor/sig_gen/method_candidate.rb +62 -0
- data/lib/rigor/sig_gen/observation_collector.rb +391 -0
- data/lib/rigor/sig_gen/observed_call.rb +62 -0
- data/lib/rigor/sig_gen/path_mapper.rb +116 -0
- data/lib/rigor/sig_gen/renderer.rb +157 -0
- data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
- data/lib/rigor/sig_gen/write_result.rb +48 -0
- data/lib/rigor/sig_gen/writer.rb +530 -0
- data/lib/rigor/sig_gen.rb +25 -0
- data/lib/rigor/trinary.rb +15 -11
- data/lib/rigor/type/bot.rb +6 -3
- data/lib/rigor/type/bound_method.rb +79 -0
- data/lib/rigor/type/combinator.rb +207 -3
- data/lib/rigor/type/constant.rb +13 -0
- data/lib/rigor/type/hash_shape.rb +0 -2
- data/lib/rigor/type/integer_range.rb +7 -7
- data/lib/rigor/type/refined.rb +18 -12
- data/lib/rigor/type/top.rb +4 -3
- data/lib/rigor/type/union.rb +20 -1
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/type_node/generic.rb +68 -0
- data/lib/rigor/type_node/identifier.rb +38 -0
- data/lib/rigor/type_node/indexed_access.rb +41 -0
- data/lib/rigor/type_node/integer_literal.rb +29 -0
- data/lib/rigor/type_node/name_scope.rb +52 -0
- data/lib/rigor/type_node/resolver_chain.rb +56 -0
- data/lib/rigor/type_node/string_literal.rb +32 -0
- data/lib/rigor/type_node/symbol_literal.rb +28 -0
- data/lib/rigor/type_node/union.rb +42 -0
- data/lib/rigor/type_node.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +2 -0
- data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
- data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
- data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
- data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
- data/sig/rigor/cli/diff_command.rbs +4 -0
- data/sig/rigor/cli/explain_command.rbs +4 -0
- data/sig/rigor/cli/sig_gen_command.rbs +4 -0
- data/sig/rigor/cli/type_scan_command.rbs +3 -0
- data/sig/rigor/environment.rbs +8 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
- data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
- data/sig/rigor/inference/builtins.rbs +2 -0
- data/sig/rigor/plugin/access_denied_error.rbs +3 -0
- data/sig/rigor/plugin/base.rbs +6 -0
- data/sig/rigor/plugin/blueprint.rbs +7 -0
- data/sig/rigor/plugin/fact_store.rbs +11 -0
- data/sig/rigor/plugin/io_boundary.rbs +4 -0
- data/sig/rigor/plugin/load_error.rbs +6 -0
- data/sig/rigor/plugin/loader.rbs +20 -0
- data/sig/rigor/plugin/manifest.rbs +9 -0
- data/sig/rigor/plugin/registry.rbs +16 -0
- data/sig/rigor/plugin/services.rbs +3 -0
- data/sig/rigor/plugin/trust_policy.rbs +4 -0
- data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
- data/sig/rigor/plugin.rbs +8 -0
- data/sig/rigor/scope.rbs +4 -2
- data/sig/rigor/type.rbs +28 -6
- data/sig/rigor.rbs +35 -2
- metadata +90 -1
|
@@ -6,15 +6,19 @@ require_relative "../environment"
|
|
|
6
6
|
require_relative "../scope"
|
|
7
7
|
require_relative "../cache/store"
|
|
8
8
|
require_relative "../plugin"
|
|
9
|
+
require_relative "../rbs_extended/reporter"
|
|
9
10
|
require_relative "../reflection"
|
|
10
11
|
require_relative "../type/combinator"
|
|
11
12
|
require_relative "../inference/coverage_scanner"
|
|
12
13
|
require_relative "../inference/scope_indexer"
|
|
14
|
+
require_relative "../inference/synthetic_method_scanner"
|
|
13
15
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
14
16
|
require_relative "check_rules"
|
|
15
17
|
require_relative "dependency_source_inference"
|
|
16
18
|
require_relative "diagnostic"
|
|
17
19
|
require_relative "result"
|
|
20
|
+
require_relative "run_stats"
|
|
21
|
+
require_relative "worker_session"
|
|
18
22
|
|
|
19
23
|
module Rigor
|
|
20
24
|
module Analysis
|
|
@@ -22,7 +26,8 @@ module Rigor
|
|
|
22
26
|
RUBY_GLOB = "**/*.rb"
|
|
23
27
|
DEFAULT_CACHE_ROOT = ".rigor/cache"
|
|
24
28
|
|
|
25
|
-
attr_reader :cache_store, :plugin_registry, :dependency_source_index
|
|
29
|
+
attr_reader :cache_store, :plugin_registry, :dependency_source_index,
|
|
30
|
+
:rbs_extended_reporter, :boundary_cross_reporter
|
|
26
31
|
|
|
27
32
|
# @param configuration [Rigor::Configuration]
|
|
28
33
|
# @param explain [Boolean] surface fail-soft fallback events
|
|
@@ -33,15 +38,33 @@ module Rigor
|
|
|
33
38
|
# run; the CLI's `--no-cache` flag wires `nil` through.
|
|
34
39
|
# v0.0.9 group A slice 1 introduces the surface; later
|
|
35
40
|
# slices route real producers through it.
|
|
41
|
+
# @param workers [Integer] ADR-15 Phase 4b — when greater
|
|
42
|
+
# than zero, per-file analysis dispatches across a pool of
|
|
43
|
+
# N Ractor workers built around {WorkerSession}. Default
|
|
44
|
+
# `0` keeps the sequential code path bit-for-bit
|
|
45
|
+
# unchanged. Phase 4c will wire the CLI / `.rigor.yml`
|
|
46
|
+
# surface that produces non-zero values; this slice
|
|
47
|
+
# leaves the parameter as a programmatic opt-in only.
|
|
48
|
+
# @param collect_stats [Boolean] when true (default), `#run`
|
|
49
|
+
# builds a {RunStats} summary exposed via `result.stats`
|
|
50
|
+
# — this forces the RBS env build at end-of-run so the
|
|
51
|
+
# `class_decl_paths` snapshot has real source attribution.
|
|
52
|
+
# Set to false to skip the stats summary entirely; the
|
|
53
|
+
# CLI's `--no-stats` threads `false` through to keep
|
|
54
|
+
# trivial-fixture runs from warming `.rigor/cache`.
|
|
36
55
|
def initialize(configuration:, explain: false,
|
|
37
56
|
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
|
|
38
|
-
plugin_requirer: nil)
|
|
57
|
+
plugin_requirer: nil, workers: 0, collect_stats: true)
|
|
39
58
|
@configuration = configuration
|
|
40
59
|
@explain = explain
|
|
41
60
|
@cache_store = cache_store
|
|
42
61
|
@plugin_requirer = plugin_requirer
|
|
62
|
+
@workers = workers
|
|
63
|
+
@collect_stats = collect_stats
|
|
43
64
|
@plugin_registry = Plugin::Registry::EMPTY
|
|
44
65
|
@dependency_source_index = DependencySourceInference::Index::EMPTY
|
|
66
|
+
@rbs_extended_reporter = RbsExtended::Reporter.new
|
|
67
|
+
@boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
|
|
45
68
|
end
|
|
46
69
|
|
|
47
70
|
# Walks every Ruby file under `paths`, parses it, builds a
|
|
@@ -56,24 +79,69 @@ module Rigor
|
|
|
56
79
|
Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
|
|
57
80
|
@configuration.fold_platform_specific_paths
|
|
58
81
|
|
|
82
|
+
wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
|
|
59
84
|
target_ruby_error = validate_target_ruby
|
|
60
85
|
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
61
86
|
|
|
62
87
|
@plugin_registry = load_plugins
|
|
63
88
|
@dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
89
|
+
expansion = expand_paths(paths)
|
|
90
|
+
@class_decl_paths_snapshot = {}.freeze
|
|
91
|
+
@signature_paths_snapshot = []
|
|
92
|
+
# ADR-16 slice 2b — Tier C pre-pass. Built once per run
|
|
93
|
+
# against the resolved file set + the loaded plugin
|
|
94
|
+
# registry's `heredoc_templates` so synthetic methods are
|
|
95
|
+
# visible cross-file when per-file inference dispatches.
|
|
96
|
+
@synthetic_method_index = Inference::SyntheticMethodScanner.scan(
|
|
68
97
|
plugin_registry: @plugin_registry,
|
|
69
|
-
|
|
98
|
+
paths: expansion.fetch(:files),
|
|
99
|
+
environment: nil
|
|
70
100
|
)
|
|
71
|
-
expansion = expand_paths(paths)
|
|
72
101
|
|
|
73
102
|
diagnostics = pre_file_diagnostics(expansion)
|
|
74
|
-
diagnostics += expansion.fetch(:files)
|
|
103
|
+
diagnostics += analyze_files(expansion.fetch(:files))
|
|
104
|
+
diagnostics += rbs_extended_reporter_diagnostics
|
|
105
|
+
diagnostics += boundary_cross_diagnostics
|
|
106
|
+
|
|
107
|
+
Result.new(
|
|
108
|
+
diagnostics: apply_severity_profile(diagnostics),
|
|
109
|
+
stats: @collect_stats ? build_run_stats(wall_started_at: wall_started_at, expansion: expansion) : nil
|
|
110
|
+
)
|
|
111
|
+
end
|
|
75
112
|
|
|
76
|
-
|
|
113
|
+
# ADR-15 Phase 4b — routes per-file analysis to either the
|
|
114
|
+
# sequential coordinator-side Environment (legacy path,
|
|
115
|
+
# default) or a Ractor worker pool built around
|
|
116
|
+
# {WorkerSession} (opt-in via `workers:`). The sequential
|
|
117
|
+
# path is bit-for-bit unchanged from v0.1.4 / earlier; the
|
|
118
|
+
# pool path is the substrate exercised by phase 4c when
|
|
119
|
+
# `RIGOR_RACTOR_WORKERS` / `.rigor.yml` `parallel.workers:`
|
|
120
|
+
# is wired.
|
|
121
|
+
#
|
|
122
|
+
# Sequential mode also snapshots `class_decl_paths` from the
|
|
123
|
+
# local environment after the per-file loop completes so
|
|
124
|
+
# `RunStats` can attribute the RBS class universe between
|
|
125
|
+
# project-sig and bundled sources. The env stays a LOCAL
|
|
126
|
+
# variable (not an ivar) so it goes GC-eligible when the
|
|
127
|
+
# method returns — holding it as long-lived state added
|
|
128
|
+
# memory pressure that surfaced as a Bus Error during the
|
|
129
|
+
# spec suite under Ruby 4.0 + rbs 4.0.2.
|
|
130
|
+
def analyze_files(files)
|
|
131
|
+
return [] if files.empty?
|
|
132
|
+
|
|
133
|
+
if pool_mode?
|
|
134
|
+
analyze_files_in_pool(files)
|
|
135
|
+
else
|
|
136
|
+
environment = build_runner_environment
|
|
137
|
+
result = files.flat_map { |path| analyze_file(path, environment) }
|
|
138
|
+
if @collect_stats
|
|
139
|
+
loader = environment.rbs_loader
|
|
140
|
+
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
141
|
+
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
142
|
+
end
|
|
143
|
+
result
|
|
144
|
+
end
|
|
77
145
|
end
|
|
78
146
|
|
|
79
147
|
# Pre-file diagnostic streams that fire once per run rather
|
|
@@ -82,12 +150,25 @@ module Rigor
|
|
|
82
150
|
# `expand_paths` errors for `paths:` entries that don't
|
|
83
151
|
# exist or aren't `.rb`. Aggregated here so `#run` stays
|
|
84
152
|
# under the ABC budget.
|
|
153
|
+
#
|
|
154
|
+
# ADR-15 Phase 4b — `plugin_prepare_diagnostics` runs on
|
|
155
|
+
# the coordinator's plugin registry under sequential mode;
|
|
156
|
+
# under pool mode each worker re-runs `prepare` against
|
|
157
|
+
# its own plugin instances, so the pool path drains the
|
|
158
|
+
# first worker's prepare-diagnostic snapshot into the
|
|
159
|
+
# aggregated diagnostic stream instead (see
|
|
160
|
+
# {#analyze_files_in_pool}). Skipping the coordinator
|
|
161
|
+
# prepare in pool mode avoids double-running `#prepare`
|
|
162
|
+
# against the coordinator-side plugin instances (which
|
|
163
|
+
# the pool path never consults for per-file analysis).
|
|
85
164
|
def pre_file_diagnostics(expansion)
|
|
165
|
+
prepare = pool_mode? ? [] : plugin_prepare_diagnostics
|
|
86
166
|
plugin_load_diagnostics +
|
|
87
|
-
|
|
167
|
+
prepare +
|
|
88
168
|
dependency_source_diagnostics +
|
|
89
169
|
dependency_source_budget_diagnostics +
|
|
90
170
|
dependency_source_config_conflict_diagnostics +
|
|
171
|
+
rbs_coverage_diagnostics +
|
|
91
172
|
expansion.fetch(:errors)
|
|
92
173
|
end
|
|
93
174
|
|
|
@@ -112,6 +193,244 @@ module Rigor
|
|
|
112
193
|
|
|
113
194
|
private
|
|
114
195
|
|
|
196
|
+
def pool_mode?
|
|
197
|
+
@workers.is_a?(Integer) && @workers.positive?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Coordinator-side Environment used by the sequential code
|
|
201
|
+
# path. Pool mode builds one Environment per worker inside
|
|
202
|
+
# the worker Ractor's body instead.
|
|
203
|
+
def build_runner_environment
|
|
204
|
+
Environment.for_project(
|
|
205
|
+
libraries: @configuration.libraries,
|
|
206
|
+
signature_paths: @configuration.signature_paths,
|
|
207
|
+
cache_store: @cache_store,
|
|
208
|
+
plugin_registry: @plugin_registry,
|
|
209
|
+
dependency_source_index: @dependency_source_index,
|
|
210
|
+
rbs_extended_reporter: @rbs_extended_reporter,
|
|
211
|
+
boundary_cross_reporter: @boundary_cross_reporter,
|
|
212
|
+
bundler_bundle_path: @configuration.bundler_bundle_path,
|
|
213
|
+
bundler_auto_detect: @configuration.bundler_auto_detect,
|
|
214
|
+
bundler_lockfile: @configuration.bundler_lockfile,
|
|
215
|
+
rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
|
|
216
|
+
rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
|
|
217
|
+
synthetic_method_index: @synthetic_method_index
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ADR-15 Phase 4b — Ractor pool around {WorkerSession}.
|
|
222
|
+
# Spawns `@workers` Ractors; each takes the shareable
|
|
223
|
+
# payload (Configuration, cache_root String, plugin
|
|
224
|
+
# Blueprint Array, explain Boolean) and builds its OWN
|
|
225
|
+
# WorkerSession internally. Files are distributed
|
|
226
|
+
# round-robin across the pool; each worker writes back to
|
|
227
|
+
# the main Ractor's mailbox via `Ractor.main.send` with
|
|
228
|
+
# one of three message kinds:
|
|
229
|
+
#
|
|
230
|
+
# - `[:prepare, diagnostics]` — once at startup, the
|
|
231
|
+
# session's `prepare_diagnostics` snapshot. The
|
|
232
|
+
# coordinator keeps the FIRST worker's snapshot only
|
|
233
|
+
# (plugin `#prepare` is deterministic per plugin, so
|
|
234
|
+
# each worker produces the same diagnostic set; surfacing
|
|
235
|
+
# them once avoids N× duplication).
|
|
236
|
+
# - `[:file, path, diagnostics]` — one per analysed file.
|
|
237
|
+
# - `[:done, drained_reporters]` — once at exit, the
|
|
238
|
+
# per-worker reporter snapshots for end-of-pool merge.
|
|
239
|
+
#
|
|
240
|
+
# The Ruby 4.0+ Ractor model uses a single per-Ractor
|
|
241
|
+
# mailbox (no `Ractor.yield`); workers push back via
|
|
242
|
+
# `Ractor.main.send`. The coordinator drains its mailbox
|
|
243
|
+
# via `Ractor.receive` until it has counted exactly
|
|
244
|
+
# `pool.size` `:done` messages.
|
|
245
|
+
#
|
|
246
|
+
# Diagnostic order: original path order. Workers may
|
|
247
|
+
# complete files out of order; the coordinator re-orders
|
|
248
|
+
# via the `results_by_path` Hash before flattening.
|
|
249
|
+
#
|
|
250
|
+
# Reporter merge: per-worker `RbsExtended::Reporter` and
|
|
251
|
+
# `BoundaryCrossReporter` entries are replayed into the
|
|
252
|
+
# runner-side accumulators via their `record_*` APIs,
|
|
253
|
+
# which dedupe on the same keys as a single-session run
|
|
254
|
+
# would. Net result: reporter state is identical to the
|
|
255
|
+
# sequential path.
|
|
256
|
+
def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
257
|
+
# Pre-warm class-level lazy memos on the MAIN Ractor.
|
|
258
|
+
# `Environment::ClassRegistry.default` is the
|
|
259
|
+
# default kwarg threaded through `Environment.new`
|
|
260
|
+
# inside each worker session; lazy-initialising it
|
|
261
|
+
# from a non-main Ractor would trip
|
|
262
|
+
# `Ractor::IsolationError`. Touching it here forces
|
|
263
|
+
# the (shareable) registry into the class-ivar cache
|
|
264
|
+
# before any worker reads.
|
|
265
|
+
Environment::ClassRegistry.default
|
|
266
|
+
|
|
267
|
+
# ADR-15 Phase 4b.x — pre-warm the RBS cache so
|
|
268
|
+
# workers serve every reflection query from the
|
|
269
|
+
# Marshal blob on disk. Without this, the first
|
|
270
|
+
# cache MISS inside a worker falls through to
|
|
271
|
+
# `RBS::EnvironmentLoader.new`, which reads a chain
|
|
272
|
+
# of non-`Ractor.shareable?` RubyGems / RBS module
|
|
273
|
+
# constants and raises `Ractor::IsolationError`.
|
|
274
|
+
# Pre-warming requires a `cache_store`; the run aborts
|
|
275
|
+
# to sequential mode otherwise. See ADR-15 Phase 4b.x
|
|
276
|
+
# for the full chain of failing constants.
|
|
277
|
+
if @cache_store.nil?
|
|
278
|
+
return analyze_files_sequentially_fallback(
|
|
279
|
+
files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
prewarm_rbs_cache_for_pool
|
|
283
|
+
|
|
284
|
+
configuration = @configuration
|
|
285
|
+
cache_root = @cache_store&.root
|
|
286
|
+
blueprints = @plugin_registry.blueprints
|
|
287
|
+
explain = @explain
|
|
288
|
+
|
|
289
|
+
pool = Array.new(@workers) do
|
|
290
|
+
Ractor.new(configuration, cache_root, blueprints, explain) do |configuration, cache_root, blueprints, explain|
|
|
291
|
+
cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
|
|
292
|
+
session = Rigor::Analysis::WorkerSession.new(
|
|
293
|
+
configuration: configuration,
|
|
294
|
+
cache_store: cache_store,
|
|
295
|
+
plugin_blueprints: blueprints,
|
|
296
|
+
explain: explain
|
|
297
|
+
)
|
|
298
|
+
main = Ractor.main
|
|
299
|
+
main.send([:prepare, session.prepare_diagnostics])
|
|
300
|
+
|
|
301
|
+
loop do
|
|
302
|
+
msg = Ractor.receive
|
|
303
|
+
break if msg.nil?
|
|
304
|
+
|
|
305
|
+
main.send([:file, msg, session.analyze(msg)])
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
main.send([:done, session.drain_reporters])
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
files.each_with_index { |path, index| pool[index % pool.size].send(path) }
|
|
313
|
+
pool.each { |worker| worker.send(nil) }
|
|
314
|
+
|
|
315
|
+
prepare_diagnostics = nil
|
|
316
|
+
results_by_path = {}
|
|
317
|
+
done_count = 0
|
|
318
|
+
|
|
319
|
+
while done_count < pool.size
|
|
320
|
+
message = Ractor.receive
|
|
321
|
+
case message.first
|
|
322
|
+
when :prepare
|
|
323
|
+
prepare_diagnostics ||= message.last
|
|
324
|
+
when :file
|
|
325
|
+
results_by_path[message[1]] = message[2]
|
|
326
|
+
when :done
|
|
327
|
+
merge_worker_reporters(message.last)
|
|
328
|
+
done_count += 1
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
pool.each(&:join)
|
|
333
|
+
|
|
334
|
+
Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# End-of-run telemetry. Walks the cached
|
|
338
|
+
# `class_decl_paths` snapshot (sequential mode: from
|
|
339
|
+
# the coordinator's environment; pool mode: from the
|
|
340
|
+
# first worker's `:prepare` payload) and partitions the
|
|
341
|
+
# RBS class universe into "project sig/" (paths under
|
|
342
|
+
# `signature_paths`) vs "bundled" (everything else).
|
|
343
|
+
# Gem source-walk counts come from `dependency_source_index`
|
|
344
|
+
# which is already constructed regardless of pool mode.
|
|
345
|
+
# Wall + RSS are single syscalls; total cost is bounded
|
|
346
|
+
# by the snapshot size (~1000-2000 entries).
|
|
347
|
+
def build_run_stats(wall_started_at:, expansion:)
|
|
348
|
+
snapshot = @class_decl_paths_snapshot || {}.freeze
|
|
349
|
+
project_sig, bundled = RunStats.partition_classes(
|
|
350
|
+
class_decl_paths: snapshot,
|
|
351
|
+
signature_paths: @signature_paths_snapshot
|
|
352
|
+
)
|
|
353
|
+
RunStats.new(
|
|
354
|
+
wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
|
|
355
|
+
peak_rss_bytes: RunStats.peak_rss_bytes,
|
|
356
|
+
target_files: expansion.fetch(:files).size,
|
|
357
|
+
rbs_classes_total: snapshot.size,
|
|
358
|
+
rbs_classes_project_sig: project_sig,
|
|
359
|
+
rbs_classes_bundled: bundled,
|
|
360
|
+
rbs_attribution_available: RunStats.attribution_available?(class_decl_paths: snapshot),
|
|
361
|
+
gem_walk_classes: @dependency_source_index.class_to_gem.size,
|
|
362
|
+
gem_walk_gems: @dependency_source_index.resolved_gems.size
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# ADR-15 Phase 4b.x — drives every cached RBS producer
|
|
367
|
+
# on the main Ractor so each worker can serve all
|
|
368
|
+
# reflection queries from disk (Marshal-load only).
|
|
369
|
+
# Builds a single coordinator-side {Environment} for
|
|
370
|
+
# this purpose; the env object is discarded immediately
|
|
371
|
+
# after the cache is warm — workers build their own
|
|
372
|
+
# `Environment.for_project` inside the Ractor body,
|
|
373
|
+
# which then routes through `cached_env` instead of
|
|
374
|
+
# `RBS::EnvironmentLoader.new`.
|
|
375
|
+
def prewarm_rbs_cache_for_pool
|
|
376
|
+
warm_env = Environment.for_project(
|
|
377
|
+
libraries: @configuration.libraries,
|
|
378
|
+
signature_paths: @configuration.signature_paths,
|
|
379
|
+
cache_store: @cache_store,
|
|
380
|
+
bundler_bundle_path: @configuration.bundler_bundle_path,
|
|
381
|
+
bundler_auto_detect: @configuration.bundler_auto_detect,
|
|
382
|
+
bundler_lockfile: @configuration.bundler_lockfile,
|
|
383
|
+
rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
|
|
384
|
+
rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
|
|
385
|
+
)
|
|
386
|
+
warm_env.rbs_loader&.prewarm
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# ADR-15 Phase 4b.x — pool-mode safety net. When pool
|
|
390
|
+
# mode is configured but a precondition fails (currently:
|
|
391
|
+
# `--no-cache` would force workers through
|
|
392
|
+
# `EnvironmentLoader.new`), degrade to sequential
|
|
393
|
+
# analysis with a `:warning` `pool-degraded` diagnostic
|
|
394
|
+
# at run start. The actual per-file analysis runs on
|
|
395
|
+
# the coordinator, identical to the default sequential
|
|
396
|
+
# path.
|
|
397
|
+
def analyze_files_sequentially_fallback(files, reason:)
|
|
398
|
+
environment = build_runner_environment
|
|
399
|
+
diagnostics = files.flat_map { |path| analyze_file(path, environment) }
|
|
400
|
+
loader = environment.rbs_loader
|
|
401
|
+
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
402
|
+
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
403
|
+
diagnostics.unshift(
|
|
404
|
+
Diagnostic.new(
|
|
405
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
406
|
+
message: "pool mode degraded to sequential: #{reason}",
|
|
407
|
+
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def merge_worker_reporters(drained)
|
|
413
|
+
rbs = drained.fetch(:rbs_extended)
|
|
414
|
+
rbs.fetch(:unresolved_payloads).each do |entry|
|
|
415
|
+
@rbs_extended_reporter.record_unresolved(
|
|
416
|
+
payload: entry.payload, source_location: entry.source_location
|
|
417
|
+
)
|
|
418
|
+
end
|
|
419
|
+
rbs.fetch(:lossy_projections).each do |entry|
|
|
420
|
+
@rbs_extended_reporter.record_lossy_projection(
|
|
421
|
+
head: entry.head, source_location: entry.source_location
|
|
422
|
+
)
|
|
423
|
+
end
|
|
424
|
+
drained.fetch(:boundary_cross).each do |entry|
|
|
425
|
+
@boundary_cross_reporter.record(
|
|
426
|
+
class_name: entry.class_name,
|
|
427
|
+
method_name: entry.method_name,
|
|
428
|
+
gem_name: entry.gem_name,
|
|
429
|
+
rbs_display: entry.rbs_display
|
|
430
|
+
)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
115
434
|
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
116
435
|
# and returns the resulting {Rigor::Plugin::Registry}. Loader
|
|
117
436
|
# failures are isolated: each surfaces as a `:plugin_loader`
|
|
@@ -296,6 +615,173 @@ module Rigor
|
|
|
296
615
|
end
|
|
297
616
|
end
|
|
298
617
|
|
|
618
|
+
# O4 Layer 3 slice 3 — graceful-degradation coverage
|
|
619
|
+
# report. When the project has a `Gemfile.lock` (slice 1)
|
|
620
|
+
# and one or more locked gems are not covered by ANY of
|
|
621
|
+
# the four RBS resolution paths (`DEFAULT_LIBRARIES`,
|
|
622
|
+
# `data/vendored_gem_sigs/`, slice-1 bundle-shipped
|
|
623
|
+
# `sig/`, slice-2 `rbs_collection.lock.yaml`), emit a
|
|
624
|
+
# single `:info` diagnostic summarising the uncovered set
|
|
625
|
+
# so the user can act on it (run `rbs collection install`,
|
|
626
|
+
# opt the gem into `dependencies.source_inference:`, or
|
|
627
|
+
# accept the `Dynamic[T]` fallback).
|
|
628
|
+
#
|
|
629
|
+
# Suppressed when the lockfile is empty, when every gem
|
|
630
|
+
# is covered, or when slice 1's `bundler.lockfile`
|
|
631
|
+
# discovery returned nothing (no lockfile to read).
|
|
632
|
+
def rbs_coverage_diagnostics
|
|
633
|
+
locked = Environment::LockfileResolver.locked_gems(
|
|
634
|
+
lockfile_path: @configuration.bundler_lockfile,
|
|
635
|
+
project_root: Dir.pwd,
|
|
636
|
+
auto_detect: @configuration.bundler_auto_detect
|
|
637
|
+
)
|
|
638
|
+
return [] if locked.empty?
|
|
639
|
+
|
|
640
|
+
bundle_sig_paths = Environment::BundleSigDiscovery.discover(
|
|
641
|
+
bundle_path: @configuration.bundler_bundle_path,
|
|
642
|
+
project_root: Dir.pwd,
|
|
643
|
+
auto_detect: @configuration.bundler_auto_detect,
|
|
644
|
+
locked_gems: locked
|
|
645
|
+
)
|
|
646
|
+
collection_paths = Environment::RbsCollectionDiscovery.discover(
|
|
647
|
+
lockfile_path: @configuration.rbs_collection_lockfile,
|
|
648
|
+
project_root: Dir.pwd,
|
|
649
|
+
auto_detect: @configuration.rbs_collection_auto_detect
|
|
650
|
+
)
|
|
651
|
+
rows = Environment::RbsCoverageReport.classify(
|
|
652
|
+
locked_gems: locked,
|
|
653
|
+
default_libraries: Environment::DEFAULT_LIBRARIES,
|
|
654
|
+
bundle_sig_paths: bundle_sig_paths,
|
|
655
|
+
rbs_collection_paths: collection_paths
|
|
656
|
+
)
|
|
657
|
+
missing = Environment::RbsCoverageReport.missing(rows)
|
|
658
|
+
return [] if missing.empty?
|
|
659
|
+
|
|
660
|
+
[build_rbs_coverage_missing_diagnostic(missing)]
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def build_rbs_coverage_missing_diagnostic(missing)
|
|
664
|
+
sample_size = 5
|
|
665
|
+
sample = missing.first(sample_size).map(&:gem_name)
|
|
666
|
+
suffix = missing.size > sample_size ? ", and #{missing.size - sample_size} more" : ""
|
|
667
|
+
Diagnostic.new(
|
|
668
|
+
path: ".rigor.yml",
|
|
669
|
+
line: 1,
|
|
670
|
+
column: 1,
|
|
671
|
+
message: "#{missing.size} gem(s) in Gemfile.lock have no RBS available: " \
|
|
672
|
+
"#{sample.join(', ')}#{suffix}. " \
|
|
673
|
+
"Consider `rbs collection install` to fetch community RBS from " \
|
|
674
|
+
"`ruby/gem_rbs_collection`, ship `sig/` in the gem itself, or " \
|
|
675
|
+
"opt the gem into `dependencies.source_inference:` in `.rigor.yml`.",
|
|
676
|
+
severity: :info,
|
|
677
|
+
rule: "rbs.coverage.missing-gem",
|
|
678
|
+
source_family: :builtin
|
|
679
|
+
)
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# ADR-13 slice 3b — drains the per-run
|
|
683
|
+
# {RbsExtended::Reporter} into one diagnostic per accumulated
|
|
684
|
+
# event:
|
|
685
|
+
#
|
|
686
|
+
# - `dynamic.rbs-extended.unresolved` for every annotation
|
|
687
|
+
# payload the parser could not turn into a {Rigor::Type}.
|
|
688
|
+
# Surfaces typos and references to plugin-supplied names
|
|
689
|
+
# the project did not enable.
|
|
690
|
+
# - `dynamic.shape.lossy-projection` for every shape-projection
|
|
691
|
+
# type function (`pick_of`, …) applied to a carrier that
|
|
692
|
+
# loses precision (anything other than `HashShape` / `Tuple`).
|
|
693
|
+
#
|
|
694
|
+
# Both are authored `:info`; the severity profile re-stamps
|
|
695
|
+
# them per project taste. Path / line / column come from the
|
|
696
|
+
# annotation's `RBS::Location` when available, falling back
|
|
697
|
+
# to `.rigor.yml`-style file-level attribution otherwise.
|
|
698
|
+
def rbs_extended_reporter_diagnostics
|
|
699
|
+
return [] if @rbs_extended_reporter.empty?
|
|
700
|
+
|
|
701
|
+
unresolved = @rbs_extended_reporter.unresolved_payloads.map do |entry|
|
|
702
|
+
build_reporter_diagnostic(
|
|
703
|
+
entry.source_location,
|
|
704
|
+
rule: "dynamic.rbs-extended.unresolved",
|
|
705
|
+
message: "`RBS::Extended` directive payload could not be resolved: " \
|
|
706
|
+
"#{entry.payload.inspect}. Check for typos or enable a plugin " \
|
|
707
|
+
"that contributes the referenced type vocabulary."
|
|
708
|
+
)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
lossy = @rbs_extended_reporter.lossy_projections.map do |entry|
|
|
712
|
+
build_reporter_diagnostic(
|
|
713
|
+
entry.source_location,
|
|
714
|
+
rule: "dynamic.shape.lossy-projection",
|
|
715
|
+
message: "Shape projection `#{entry.head}` applied to a carrier without a " \
|
|
716
|
+
"literal shape; the projection degrades to the input type. Author " \
|
|
717
|
+
"a `HashShape` / `Tuple` carrier or accept the unchanged result."
|
|
718
|
+
)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
unresolved + lossy
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# ADR-10 slice 5c — drains the per-run
|
|
725
|
+
# {DependencySourceInference::BoundaryCrossReporter} into
|
|
726
|
+
# `dynamic.dependency-source.boundary-cross` `:info`
|
|
727
|
+
# diagnostics. Each event flags a call site where RBS
|
|
728
|
+
# dispatch produced a concrete answer AND a `mode: :full`
|
|
729
|
+
# opt-in gem's source catalog ALSO contains an entry for
|
|
730
|
+
# the same `(class_name, method_name)` — i.e., both
|
|
731
|
+
# contracts have an opinion. RBS still wins on the
|
|
732
|
+
# dispatch result; the diagnostic is purely advisory so
|
|
733
|
+
# the user can verify the two contracts haven't drifted.
|
|
734
|
+
#
|
|
735
|
+
# Severity profile re-stamps the rule per project taste.
|
|
736
|
+
# The diagnostic carries no `path` / `line` / `column`
|
|
737
|
+
# because the crossing is per-method-per-gem, not
|
|
738
|
+
# per-call-site — the diagnostic anchors at `.rigor.yml`
|
|
739
|
+
# like the other `dependency-source.*` diagnostics that
|
|
740
|
+
# report on opt-in configuration.
|
|
741
|
+
def boundary_cross_diagnostics
|
|
742
|
+
return [] if @boundary_cross_reporter.empty?
|
|
743
|
+
|
|
744
|
+
@boundary_cross_reporter.entries.map do |entry|
|
|
745
|
+
Diagnostic.new(
|
|
746
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
747
|
+
message: "`#{entry.class_name}##{entry.method_name}` is contributed by both " \
|
|
748
|
+
"RBS (#{entry.rbs_display}) and the `mode: :full` opt-in gem " \
|
|
749
|
+
"`#{entry.gem_name}`. RBS wins on dispatch; verify the gem source " \
|
|
750
|
+
"has not drifted from its RBS contract.",
|
|
751
|
+
severity: :info,
|
|
752
|
+
rule: "dynamic.dependency-source.boundary-cross",
|
|
753
|
+
source_family: :builtin
|
|
754
|
+
)
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def build_reporter_diagnostic(source_location, rule:, message:)
|
|
759
|
+
path, line, column = location_fields(source_location)
|
|
760
|
+
Diagnostic.new(
|
|
761
|
+
path: path, line: line, column: column,
|
|
762
|
+
message: message, severity: :info, rule: rule, source_family: :builtin
|
|
763
|
+
)
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def location_fields(source_location)
|
|
767
|
+
return [".rigor.yml", 1, 1] if source_location.nil?
|
|
768
|
+
|
|
769
|
+
path = location_path(source_location)
|
|
770
|
+
line = source_location.respond_to?(:start_line) ? source_location.start_line : 1
|
|
771
|
+
column = source_location.respond_to?(:start_column) ? source_location.start_column + 1 : 1
|
|
772
|
+
[path, line, column]
|
|
773
|
+
rescue StandardError
|
|
774
|
+
[".rigor.yml", 1, 1]
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def location_path(source_location)
|
|
778
|
+
buffer = source_location.respond_to?(:buffer) ? source_location.buffer : nil
|
|
779
|
+
return ".rigor.yml" if buffer.nil? || !buffer.respond_to?(:name)
|
|
780
|
+
|
|
781
|
+
name = buffer.name.to_s
|
|
782
|
+
name.empty? ? ".rigor.yml" : name
|
|
783
|
+
end
|
|
784
|
+
|
|
299
785
|
# ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
|
|
300
786
|
# hook once per run, after the loader's `#init` pass and
|
|
301
787
|
# before per-file iteration. Plugins publish facts here
|
|
@@ -453,7 +939,7 @@ module Rigor
|
|
|
453
939
|
parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
|
|
454
940
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
455
941
|
|
|
456
|
-
scope = Scope.empty(environment: environment)
|
|
942
|
+
scope = Scope.empty(environment: environment, source_path: path)
|
|
457
943
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
458
944
|
diagnostics = CheckRules.diagnose(
|
|
459
945
|
path: path,
|