rigortype 0.1.17 → 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/unreachable_clause_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules.rb +34 -6
- 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 +160 -1190
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli.rb +15 -614
- data/lib/rigor/configuration.rb +9 -6
- data/lib/rigor/environment/rbs_loader.rb +53 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/expression_typer.rb +28 -62
- 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/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher.rb +115 -54
- data/lib/rigor/inference/narrowing.rb +60 -0
- data/lib/rigor/inference/scope_indexer.rb +75 -15
- data/lib/rigor/inference/statement_evaluator.rb +35 -52
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +282 -41
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +263 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +67 -198
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/combinator.rb +5 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -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-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/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/scope.rbs +41 -29
- data/sig/rigor/source.rbs +1 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +15 -2
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
|
@@ -32,6 +32,10 @@ require_relative "project_scan"
|
|
|
32
32
|
require_relative "result"
|
|
33
33
|
require_relative "run_stats"
|
|
34
34
|
require_relative "worker_session"
|
|
35
|
+
require_relative "runner/run_snapshots"
|
|
36
|
+
require_relative "runner/project_pre_passes"
|
|
37
|
+
require_relative "runner/pool_coordinator"
|
|
38
|
+
require_relative "runner/diagnostic_aggregator"
|
|
35
39
|
|
|
36
40
|
module Rigor
|
|
37
41
|
module Analysis
|
|
@@ -141,17 +145,13 @@ module Rigor
|
|
|
141
145
|
# `#run` resets these for each invocation; pre-seed them to
|
|
142
146
|
# empty containers so `build_run_stats` / `pre_file_diagnostics`
|
|
143
147
|
# (private, called only from `#run`) can read them without
|
|
144
|
-
# nil-guards.
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
# the project declaring `signature_paths:`) and drained by
|
|
152
|
-
# `conforms_to_diagnostics`. Inline default per the comment
|
|
153
|
-
# above so the engine's own flow analysis sees it seeded.
|
|
154
|
-
@conformance_results_snapshot = [].freeze
|
|
148
|
+
# nil-guards. The four end-of-pass snapshots (RBS class /
|
|
149
|
+
# signature-path tables, synthesized-namespace names,
|
|
150
|
+
# `rigor:v1:conforms-to` results) live in one shared mutable
|
|
151
|
+
# {RunSnapshots} sink so the analysis path that writes them and
|
|
152
|
+
# the run / aggregator code that reads them stay in separate
|
|
153
|
+
# collaborators without a back-reference cycle.
|
|
154
|
+
@snapshots = RunSnapshots.new
|
|
155
155
|
@cached_plugin_prepare_diagnostics = [].freeze
|
|
156
156
|
@project_discovered_classes = {}.freeze
|
|
157
157
|
@project_discovered_def_nodes = {}.freeze
|
|
@@ -162,6 +162,7 @@ module Rigor
|
|
|
162
162
|
@project_discovered_method_visibilities = {}.freeze
|
|
163
163
|
@project_discovered_methods = {}.freeze
|
|
164
164
|
@project_data_member_layouts = {}.freeze
|
|
165
|
+
build_collaborators
|
|
165
166
|
end
|
|
166
167
|
|
|
167
168
|
# ADR-pending editor mode — present when the runner is wired
|
|
@@ -188,10 +189,7 @@ module Rigor
|
|
|
188
189
|
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
189
190
|
|
|
190
191
|
expansion = expand_paths(paths)
|
|
191
|
-
@
|
|
192
|
-
@signature_paths_snapshot = []
|
|
193
|
-
@synthesized_namespaces_snapshot = []
|
|
194
|
-
@conformance_results_snapshot = []
|
|
192
|
+
@snapshots.reset_for_run
|
|
195
193
|
|
|
196
194
|
if @prebuilt
|
|
197
195
|
adopt_prebuilt_project_scan(@prebuilt)
|
|
@@ -202,7 +200,7 @@ module Rigor
|
|
|
202
200
|
diagnostics = compute_run_diagnostics(expansion)
|
|
203
201
|
|
|
204
202
|
Result.new(
|
|
205
|
-
diagnostics: apply_severity_profile(diagnostics),
|
|
203
|
+
diagnostics: @diagnostic_aggregator.apply_severity_profile(diagnostics),
|
|
206
204
|
stats: stats_for_run(wall_started_at: wall_started_at, expansion: expansion)
|
|
207
205
|
)
|
|
208
206
|
end
|
|
@@ -298,7 +296,7 @@ module Rigor
|
|
|
298
296
|
@run_served_from_cache = false
|
|
299
297
|
return assemble_run_diagnostics(expansion) unless run_result_cacheable?
|
|
300
298
|
|
|
301
|
-
environment = resolve_sequential_environment(source_files: target_files(expansion))
|
|
299
|
+
environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
|
|
302
300
|
rbs_descriptor = environment&.rbs_loader ? Cache::RbsDescriptor.build(environment.rbs_loader) : Cache::Descriptor.new
|
|
303
301
|
key_descriptor = run_key_descriptor(expansion, rbs_descriptor)
|
|
304
302
|
return assemble_run_diagnostics(expansion, environment: environment) if key_descriptor.nil?
|
|
@@ -321,7 +319,7 @@ module Rigor
|
|
|
321
319
|
end
|
|
322
320
|
|
|
323
321
|
def assemble_run_diagnostics(expansion, environment: nil)
|
|
324
|
-
diagnostics = pre_file_diagnostics(expansion)
|
|
322
|
+
diagnostics = @diagnostic_aggregator.pre_file_diagnostics(expansion)
|
|
325
323
|
# ADR-46 — record which project files this run actually analyzed
|
|
326
324
|
# (the `analyze_only` subset, or all of them). The incremental
|
|
327
325
|
# orchestrator serves every analyzed-but-not-affected file from the
|
|
@@ -329,12 +327,12 @@ module Rigor
|
|
|
329
327
|
# affected closure from.
|
|
330
328
|
targets = target_files(expansion)
|
|
331
329
|
@analyzed_files = targets
|
|
332
|
-
diagnostics += analyze_files(targets, environment: environment)
|
|
333
|
-
diagnostics += rbs_synthesized_namespace_diagnostics
|
|
334
|
-
diagnostics += conforms_to_diagnostics
|
|
335
|
-
diagnostics += rbs_extended_reporter_diagnostics
|
|
336
|
-
diagnostics += boundary_cross_diagnostics
|
|
337
|
-
diagnostics + source_rbs_synthesis_diagnostics
|
|
330
|
+
diagnostics += @pool_coordinator.analyze_files(targets, environment: environment)
|
|
331
|
+
diagnostics += @diagnostic_aggregator.rbs_synthesized_namespace_diagnostics
|
|
332
|
+
diagnostics += @diagnostic_aggregator.conforms_to_diagnostics
|
|
333
|
+
diagnostics += @diagnostic_aggregator.rbs_extended_reporter_diagnostics
|
|
334
|
+
diagnostics += @diagnostic_aggregator.boundary_cross_diagnostics
|
|
335
|
+
diagnostics + @diagnostic_aggregator.source_rbs_synthesis_diagnostics
|
|
338
336
|
end
|
|
339
337
|
|
|
340
338
|
# A cache hit skipped the analysis, so the per-run stats (wall
|
|
@@ -429,88 +427,18 @@ module Rigor
|
|
|
429
427
|
# already populated for subsequent dispatch use.
|
|
430
428
|
def prepare_project_scan(paths: @configuration.paths)
|
|
431
429
|
expansion = expand_paths(paths)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
dependency_source_index: @dependency_source_index,
|
|
436
|
-
synthetic_method_index: @synthetic_method_index,
|
|
437
|
-
project_patched_methods: @project_patched_methods,
|
|
438
|
-
plugin_prepare_diagnostics: @cached_plugin_prepare_diagnostics.dup.freeze,
|
|
439
|
-
pre_eval_diagnostics: @pre_eval_diagnostics_from_scanner.dup.freeze
|
|
440
|
-
)
|
|
430
|
+
result = @pre_passes.run(expansion: expansion)
|
|
431
|
+
apply_pre_passes_result(result)
|
|
432
|
+
@pre_passes.build_project_scan(result)
|
|
441
433
|
end
|
|
442
434
|
|
|
443
|
-
# Internal: drives every project-wide pre-pass
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
# `#
|
|
447
|
-
#
|
|
448
|
-
def run_project_pre_passes(expansion:)
|
|
449
|
-
@
|
|
450
|
-
@dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
|
|
451
|
-
# ADR-18 slice 3 — plugin prepare MUST run before the
|
|
452
|
-
# synthetic-method scanner so cross-plugin facts
|
|
453
|
-
# (`:dry_type_aliases` etc.) are already published when
|
|
454
|
-
# the scanner resolves Tier C `returns_from_arg:`
|
|
455
|
-
# lookups. The diagnostics produced by prepare are
|
|
456
|
-
# captured here so `pre_file_diagnostics` can re-emit
|
|
457
|
-
# them in the existing order without invoking prepare
|
|
458
|
-
# twice. Pool mode still re-runs prepare per worker
|
|
459
|
-
# (workers don't see this early invocation), preserving
|
|
460
|
-
# the existing Phase 4b contract.
|
|
461
|
-
@cached_plugin_prepare_diagnostics =
|
|
462
|
-
pool_mode? ? [] : plugin_prepare_diagnostics
|
|
463
|
-
# ADR-16 slice 2b — Tier C pre-pass. Built once per run
|
|
464
|
-
# against the resolved file set + the loaded plugin
|
|
465
|
-
# registry's `heredoc_templates` so synthetic methods are
|
|
466
|
-
# visible cross-file when per-file inference dispatches.
|
|
467
|
-
@synthetic_method_index = Inference::SyntheticMethodScanner.scan(
|
|
468
|
-
plugin_registry: @plugin_registry,
|
|
469
|
-
paths: expansion.fetch(:files),
|
|
470
|
-
environment: nil,
|
|
471
|
-
fact_store: shared_fact_store,
|
|
472
|
-
buffer: @buffer
|
|
473
|
-
)
|
|
474
|
-
# ADR-17 slice 2 — pre-eval pre-pass. Built once per run
|
|
475
|
-
# from the `pre_eval:` entries that exist on disk
|
|
476
|
-
# (slice-1's `pre-eval.file-not-found` `:error` already
|
|
477
|
-
# surfaced any missing entries; the scanner skips them
|
|
478
|
-
# here). The resulting {ProjectPatchedMethods} registry
|
|
479
|
-
# is consulted by the dispatcher tier between plugins
|
|
480
|
-
# and dependency-source inference so project-side
|
|
481
|
-
# patches resolve cross-file.
|
|
482
|
-
existing_pre_eval = @configuration.pre_eval.select { |path| File.file?(path) }
|
|
483
|
-
pre_eval_outcome = Inference::ProjectPatchedScanner.scan(existing_pre_eval, buffer: @buffer)
|
|
484
|
-
@project_patched_methods = pre_eval_outcome.registry
|
|
485
|
-
@pre_eval_diagnostics_from_scanner = pre_eval_outcome.diagnostics
|
|
486
|
-
# Cross-file class discovery — walks every project file
|
|
487
|
-
# for `class Foo` / `module Bar` declarations so a
|
|
488
|
-
# `Foo.method_call` receiver in one file resolves a
|
|
489
|
-
# `class Foo` declared in a sibling file. Without this
|
|
490
|
-
# pre-pass each file's `discovered_classes` was per-file
|
|
491
|
-
# only, and lexical lookup fell back to stdlib `::Foo`
|
|
492
|
-
# for any user class shadowing a stdlib name (e.g.
|
|
493
|
-
# `Google::Cloud::Storage::File`). Cost is one extra
|
|
494
|
-
# parse pass over the project; small projects pay
|
|
495
|
-
# tens of ms, larger projects ~1s. Future optimisation
|
|
496
|
-
# can share parses with the existing scanner passes.
|
|
497
|
-
@project_discovered_classes =
|
|
498
|
-
Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
499
|
-
# ADR-24 slice 2 — cross-file def-node + class->superclass
|
|
500
|
-
# index so an implicit-self call inside a subclass
|
|
501
|
-
# resolves a superclass `def` declared in a sibling
|
|
502
|
-
# file. One extra parse pass over the project; shares
|
|
503
|
-
# the cost profile of the class-discovery pass above.
|
|
504
|
-
def_index =
|
|
505
|
-
Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
506
|
-
@project_discovered_def_nodes = def_index.fetch(:def_nodes)
|
|
507
|
-
@project_discovered_def_sources = def_index.fetch(:def_sources)
|
|
508
|
-
@project_discovered_superclasses = def_index.fetch(:superclasses)
|
|
509
|
-
@project_discovered_includes = def_index.fetch(:includes)
|
|
510
|
-
@project_discovered_class_sources = def_index.fetch(:class_sources)
|
|
511
|
-
@project_discovered_method_visibilities = def_index.fetch(:method_visibilities)
|
|
512
|
-
@project_discovered_methods = def_index.fetch(:methods)
|
|
513
|
-
@project_data_member_layouts = def_index.fetch(:data_member_layouts)
|
|
435
|
+
# Internal: drives every project-wide pre-pass through the
|
|
436
|
+
# {ProjectPrePasses} collaborator and adopts the resulting
|
|
437
|
+
# state onto the runner's ivar surface in the order the
|
|
438
|
+
# downstream `#run` body expects. Shared by `#prepare_project_scan`
|
|
439
|
+
# and the prebuilt-less `#run` path.
|
|
440
|
+
def run_project_pre_passes(expansion:)
|
|
441
|
+
apply_pre_passes_result(@pre_passes.run(expansion: expansion))
|
|
514
442
|
end
|
|
515
443
|
|
|
516
444
|
# Internal: adopts a frozen {ProjectScan} snapshot supplied
|
|
@@ -518,161 +446,37 @@ module Rigor
|
|
|
518
446
|
# the runner's ivar surface, mirroring what
|
|
519
447
|
# `run_project_pre_passes` would have produced.
|
|
520
448
|
def adopt_prebuilt_project_scan(scan)
|
|
521
|
-
@
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
#
|
|
531
|
-
#
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
def analyze_files_sequentially(files, environment)
|
|
555
|
-
# Snapshot the small synthesized-namespace name list (NOT the
|
|
556
|
-
# env — see the method comment) so #run can surface the
|
|
557
|
-
# malformed-RBS `:info` diagnostic without rebuilding the env.
|
|
558
|
-
# Gated on the project actually declaring `signature_paths:`:
|
|
559
|
-
# synthesis only matters for the project's own RBS, and
|
|
560
|
-
# `#synthesized_namespaces` forces the (otherwise-lazy) RBS env
|
|
561
|
-
# to build — doing so when there is no project sig set would
|
|
562
|
-
# warm `.rigor/cache` on a bare `--no-stats` run.
|
|
563
|
-
@synthesized_namespaces_snapshot =
|
|
564
|
-
project_signature_paths? ? (environment.rbs_loader&.synthesized_namespaces || []) : []
|
|
565
|
-
# `rigor:v1:conforms-to` lives only in the project's own
|
|
566
|
-
# `signature_paths:` RBS, so gate the scan the same way and
|
|
567
|
-
# reuse the already-built env (no extra RBS load).
|
|
568
|
-
@conformance_results_snapshot =
|
|
569
|
-
project_signature_paths? ? RbsExtended::ConformanceChecker.scan(environment.rbs_loader) : []
|
|
570
|
-
result = files.flat_map { |path| analyze_file(path, environment) }
|
|
571
|
-
if @collect_stats
|
|
572
|
-
loader = environment.rbs_loader
|
|
573
|
-
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
574
|
-
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
575
|
-
end
|
|
576
|
-
result
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
# Sequential-mode environment resolver. Returns the supplied
|
|
580
|
-
# `environment:` override (with the runner's fresh per-run
|
|
581
|
-
# reporter pair attached so dispatcher events route to THIS
|
|
582
|
-
# runner's diagnostics) when present; otherwise builds a
|
|
583
|
-
# fresh Environment per-call via {#build_runner_environment}
|
|
584
|
-
# — preserving the pre-override behaviour bit-for-bit.
|
|
585
|
-
def resolve_sequential_environment(source_files: [])
|
|
586
|
-
return build_runner_environment(source_files: source_files) unless @environment_override
|
|
587
|
-
|
|
588
|
-
@environment_override.attach_reporters!(
|
|
589
|
-
rbs_extended_reporter: @rbs_extended_reporter,
|
|
590
|
-
boundary_cross_reporter: @boundary_cross_reporter
|
|
591
|
-
)
|
|
592
|
-
@environment_override
|
|
593
|
-
end
|
|
594
|
-
private :resolve_sequential_environment
|
|
595
|
-
|
|
596
|
-
# Pre-file diagnostic streams that fire once per run rather
|
|
597
|
-
# than per analyzed file: plugin load / prepare envelopes,
|
|
598
|
-
# the ADR-10 dependency-source resolution surface, and the
|
|
599
|
-
# `expand_paths` errors for `paths:` entries that don't
|
|
600
|
-
# exist or aren't `.rb`. Aggregated here so `#run` stays
|
|
601
|
-
# under the ABC budget.
|
|
602
|
-
#
|
|
603
|
-
# ADR-15 Phase 4b — `plugin_prepare_diagnostics` runs on
|
|
604
|
-
# the coordinator's plugin registry under sequential mode;
|
|
605
|
-
# under pool mode each worker re-runs `prepare` against
|
|
606
|
-
# its own plugin instances, so the pool path drains the
|
|
607
|
-
# first worker's prepare-diagnostic snapshot into the
|
|
608
|
-
# aggregated diagnostic stream instead (see
|
|
609
|
-
# {#analyze_files_in_pool}). Skipping the coordinator
|
|
610
|
-
# prepare in pool mode avoids double-running `#prepare`
|
|
611
|
-
# against the coordinator-side plugin instances (which
|
|
612
|
-
# the pool path never consults for per-file analysis).
|
|
613
|
-
def pre_file_diagnostics(expansion)
|
|
614
|
-
# ADR-18 slice 3 — prepare diagnostics are captured
|
|
615
|
-
# earlier in #run (before the synthetic-method scanner)
|
|
616
|
-
# so cross-plugin facts are available to the scanner.
|
|
617
|
-
# We re-surface the captured diagnostics here so the
|
|
618
|
-
# existing pre_file_diagnostics ordering is preserved.
|
|
619
|
-
prepare = pool_mode? ? [] : @cached_plugin_prepare_diagnostics
|
|
620
|
-
plugin_load_diagnostics +
|
|
621
|
-
prepare +
|
|
622
|
-
pre_eval_diagnostics +
|
|
623
|
-
dependency_source_diagnostics +
|
|
624
|
-
dependency_source_budget_diagnostics +
|
|
625
|
-
dependency_source_config_conflict_diagnostics +
|
|
626
|
-
rbs_coverage_diagnostics +
|
|
627
|
-
expansion.fetch(:errors)
|
|
628
|
-
end
|
|
629
|
-
|
|
630
|
-
# Returns the per-run shared `Plugin::FactStore` instance.
|
|
631
|
-
# All loaded plugins share this store through their
|
|
632
|
-
# respective `Plugin::Services` (the same instance is
|
|
633
|
-
# threaded by `Plugin::Loader.load`). Returns `nil` when
|
|
634
|
-
# no plugins are loaded.
|
|
635
|
-
def shared_fact_store
|
|
636
|
-
return nil if @plugin_registry.nil? || @plugin_registry.empty?
|
|
637
|
-
|
|
638
|
-
@plugin_registry.plugins.first&.services&.fact_store
|
|
639
|
-
end
|
|
640
|
-
|
|
641
|
-
# ADR-17 slice 1 — surface a `:error` diagnostic for each
|
|
642
|
-
# `pre_eval:` entry whose resolved path doesn't exist on
|
|
643
|
-
# disk. Loud failure mode (`:error`, not `:warning`):
|
|
644
|
-
# a missing pre_eval path is a configuration mistake the
|
|
645
|
-
# user must fix before analysis is meaningful.
|
|
646
|
-
#
|
|
647
|
-
# Slice 2 adds the `:warning` `pre-eval.parse-error`
|
|
648
|
-
# stream from the pre-pass scanner — accumulated as
|
|
649
|
-
# `@pre_eval_diagnostics_from_scanner` during {#run} and
|
|
650
|
-
# merged here so both diagnostics flow through the same
|
|
651
|
-
# severity / ordering pipeline.
|
|
652
|
-
def pre_eval_diagnostics
|
|
653
|
-
not_found = @configuration.pre_eval.filter_map do |path|
|
|
654
|
-
next if File.file?(path)
|
|
655
|
-
|
|
656
|
-
Diagnostic.new(
|
|
657
|
-
path: ".rigor.yml", line: 1, column: 1,
|
|
658
|
-
message: "pre_eval entry not found: #{path.inspect}. " \
|
|
659
|
-
"Pre-evaluation requires the file to exist on disk; remove the entry " \
|
|
660
|
-
"or create the file before re-running analysis.",
|
|
661
|
-
severity: :error,
|
|
662
|
-
rule: "pre-eval.file-not-found",
|
|
663
|
-
source_family: :builtin
|
|
664
|
-
)
|
|
665
|
-
end
|
|
666
|
-
not_found + Array(@pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
def diagnostic_from_hash(hash)
|
|
670
|
-
Diagnostic.new(
|
|
671
|
-
path: hash.fetch(:path), line: hash.fetch(:line), column: hash.fetch(:column),
|
|
672
|
-
message: hash.fetch(:message), severity: hash.fetch(:severity),
|
|
673
|
-
rule: hash.fetch(:rule), source_family: :builtin
|
|
674
|
-
)
|
|
675
|
-
end
|
|
449
|
+
apply_pre_passes_result(@pre_passes.adopt_prebuilt(scan))
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Internal: copies a {ProjectPrePasses::Result} bundle onto the
|
|
453
|
+
# runner's ivars in the assignment order the original inline
|
|
454
|
+
# pre-pass body used, so every downstream reader (per-file
|
|
455
|
+
# analysis seed, pool environment build, diagnostic aggregator)
|
|
456
|
+
# sees the same ivar surface. The prebuilt path leaves the
|
|
457
|
+
# discovery tables at their frozen-empty constructor defaults
|
|
458
|
+
# (the bundle carries `nil` for them, matching the original
|
|
459
|
+
# adopt path that never touched them).
|
|
460
|
+
def apply_pre_passes_result(result)
|
|
461
|
+
@plugin_registry = result.plugin_registry
|
|
462
|
+
@dependency_source_index = result.dependency_source_index
|
|
463
|
+
@cached_plugin_prepare_diagnostics = result.cached_plugin_prepare_diagnostics
|
|
464
|
+
@synthetic_method_index = result.synthetic_method_index
|
|
465
|
+
@project_patched_methods = result.project_patched_methods
|
|
466
|
+
@pre_eval_diagnostics_from_scanner = result.pre_eval_diagnostics_from_scanner
|
|
467
|
+
@project_discovered_classes = result.discovered_classes if result.discovered_classes
|
|
468
|
+
@project_discovered_def_nodes = result.discovered_def_nodes if result.discovered_def_nodes
|
|
469
|
+
@project_discovered_def_sources = result.discovered_def_sources if result.discovered_def_sources
|
|
470
|
+
@project_discovered_superclasses = result.discovered_superclasses if result.discovered_superclasses
|
|
471
|
+
@project_discovered_includes = result.discovered_includes if result.discovered_includes
|
|
472
|
+
@project_discovered_class_sources = result.discovered_class_sources if result.discovered_class_sources
|
|
473
|
+
if result.discovered_method_visibilities
|
|
474
|
+
@project_discovered_method_visibilities = result.discovered_method_visibilities
|
|
475
|
+
end
|
|
476
|
+
@project_discovered_methods = result.discovered_methods if result.discovered_methods
|
|
477
|
+
@project_data_member_layouts = result.data_member_layouts if result.data_member_layouts
|
|
478
|
+
end
|
|
479
|
+
private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result
|
|
676
480
|
|
|
677
481
|
# `target_ruby` flows through to Prism's `version:` option.
|
|
678
482
|
# Prism enforces the supported range and raises
|
|
@@ -735,318 +539,57 @@ module Rigor
|
|
|
735
539
|
Cache::Store.new(root: cache_store.root, read_only: true)
|
|
736
540
|
end
|
|
737
541
|
|
|
738
|
-
#
|
|
739
|
-
#
|
|
740
|
-
#
|
|
741
|
-
#
|
|
742
|
-
#
|
|
743
|
-
#
|
|
744
|
-
#
|
|
745
|
-
#
|
|
746
|
-
#
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
# the Ruby Bug #22075 use-after-free and the worker-side
|
|
756
|
-
# `Ractor::IsolationError` that make the Ractor pool unusable
|
|
757
|
-
# (see the ADR-15 Amendment +
|
|
758
|
-
# docs/notes/20260520-ractor-pool-cruby-uaf.md). The Ractor pool
|
|
759
|
-
# is preserved but off the default path — `RIGOR_POOL_BACKEND=ractor`
|
|
760
|
-
# opts back in so it stays testable. Platforms without `fork`
|
|
761
|
-
# (Windows) fall back to sequential.
|
|
762
|
-
def pool_backend
|
|
763
|
-
return :ractor if ENV["RIGOR_POOL_BACKEND"] == "ractor"
|
|
764
|
-
return :fork if Process.respond_to?(:fork)
|
|
765
|
-
|
|
766
|
-
:sequential
|
|
767
|
-
end
|
|
768
|
-
|
|
769
|
-
# Routes pool-mode analysis to the selected backend.
|
|
770
|
-
def dispatch_pool(files)
|
|
771
|
-
case pool_backend
|
|
772
|
-
when :ractor then analyze_files_in_pool(files)
|
|
773
|
-
when :fork then analyze_files_in_fork_pool(files)
|
|
774
|
-
else
|
|
775
|
-
analyze_files_sequentially_fallback(
|
|
776
|
-
files, reason: "fork-based parallelism is unavailable on this platform"
|
|
777
|
-
)
|
|
778
|
-
end
|
|
779
|
-
end
|
|
780
|
-
|
|
781
|
-
# Coordinator-side Environment used by the sequential code
|
|
782
|
-
# path. Pool mode builds one Environment per worker inside
|
|
783
|
-
# the worker Ractor's body instead.
|
|
784
|
-
#
|
|
785
|
-
# ADR-32 WD4 — `source_files:` is threaded down so that
|
|
786
|
-
# `Environment.for_project` can invoke each loaded plugin's
|
|
787
|
-
# `source_rbs_synthesizer` callable per project source file
|
|
788
|
-
# at env-build time. Defaults to `[]` for callers that don't
|
|
789
|
-
# have a file list yet (e.g. pre-pass-only build paths); in
|
|
790
|
-
# that case no synthesised RBS is contributed.
|
|
791
|
-
def build_runner_environment(source_files: [])
|
|
792
|
-
Environment.for_project(
|
|
793
|
-
libraries: @configuration.libraries,
|
|
794
|
-
signature_paths: @configuration.signature_paths,
|
|
795
|
-
cache_store: @cache_store,
|
|
796
|
-
plugin_registry: @plugin_registry,
|
|
797
|
-
dependency_source_index: @dependency_source_index,
|
|
542
|
+
# Wires the three responsibility collaborators. Called at the end
|
|
543
|
+
# of construction (after every state ivar is seeded). The per-run
|
|
544
|
+
# varying state (the plugin registry, dependency-source / scanner
|
|
545
|
+
# indexes, prepare-diagnostic snapshot, and the four end-of-pass
|
|
546
|
+
# snapshots) is reached through reader procs so each collaborator
|
|
547
|
+
# observes the live ivar value at call time without a
|
|
548
|
+
# back-reference cycle. The reporter accumulators and the
|
|
549
|
+
# {RunSnapshots} sink are shared mutable instances.
|
|
550
|
+
def build_collaborators # rubocop:disable Metrics/MethodLength
|
|
551
|
+
@pre_passes = ProjectPrePasses.new(
|
|
552
|
+
configuration: @configuration, cache_store: @cache_store, buffer: @buffer,
|
|
553
|
+
plugin_requirer: @plugin_requirer, pool_mode: -> { pool_mode? }
|
|
554
|
+
)
|
|
555
|
+
@pool_coordinator = PoolCoordinator.new(
|
|
556
|
+
configuration: @configuration, cache_store: @cache_store, explain: @explain,
|
|
557
|
+
workers: @workers, collect_stats: @collect_stats, buffer: @buffer,
|
|
558
|
+
environment_override: @environment_override,
|
|
798
559
|
rbs_extended_reporter: @rbs_extended_reporter,
|
|
799
560
|
boundary_cross_reporter: @boundary_cross_reporter,
|
|
800
561
|
source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
end
|
|
811
|
-
|
|
812
|
-
# ADR-15 Phase 4b — Ractor pool around {WorkerSession}.
|
|
813
|
-
# Spawns `@workers` Ractors; each takes the shareable
|
|
814
|
-
# payload (Configuration, cache_root String, plugin
|
|
815
|
-
# Blueprint Array, explain Boolean) and builds its OWN
|
|
816
|
-
# WorkerSession internally. Files are distributed
|
|
817
|
-
# round-robin across the pool; each worker writes back to
|
|
818
|
-
# the main Ractor's mailbox via `Ractor.main.send` with
|
|
819
|
-
# one of three message kinds:
|
|
820
|
-
#
|
|
821
|
-
# - `[:prepare, diagnostics]` — once at startup, the
|
|
822
|
-
# session's `prepare_diagnostics` snapshot. The
|
|
823
|
-
# coordinator keeps the FIRST worker's snapshot only
|
|
824
|
-
# (plugin `#prepare` is deterministic per plugin, so
|
|
825
|
-
# each worker produces the same diagnostic set; surfacing
|
|
826
|
-
# them once avoids N× duplication).
|
|
827
|
-
# - `[:file, path, diagnostics]` — one per analysed file.
|
|
828
|
-
# - `[:done, drained_reporters]` — once at exit, the
|
|
829
|
-
# per-worker reporter snapshots for end-of-pool merge.
|
|
830
|
-
#
|
|
831
|
-
# The Ruby 4.0+ Ractor model uses a single per-Ractor
|
|
832
|
-
# mailbox (no `Ractor.yield`); workers push back via
|
|
833
|
-
# `Ractor.main.send`. The coordinator drains its mailbox
|
|
834
|
-
# via `Ractor.receive` until it has counted exactly
|
|
835
|
-
# `pool.size` `:done` messages.
|
|
836
|
-
#
|
|
837
|
-
# Diagnostic order: original path order. Workers may
|
|
838
|
-
# complete files out of order; the coordinator re-orders
|
|
839
|
-
# via the `results_by_path` Hash before flattening.
|
|
840
|
-
#
|
|
841
|
-
# Reporter merge: per-worker `RbsExtended::Reporter` and
|
|
842
|
-
# `BoundaryCrossReporter` entries are replayed into the
|
|
843
|
-
# runner-side accumulators via their `record_*` APIs,
|
|
844
|
-
# which dedupe on the same keys as a single-session run
|
|
845
|
-
# would. Net result: reporter state is identical to the
|
|
846
|
-
# sequential path.
|
|
847
|
-
def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
848
|
-
# Pre-warm class-level lazy memos on the MAIN Ractor.
|
|
849
|
-
# `Environment::ClassRegistry.default` is the
|
|
850
|
-
# default kwarg threaded through `Environment.new`
|
|
851
|
-
# inside each worker session; lazy-initialising it
|
|
852
|
-
# from a non-main Ractor would trip
|
|
853
|
-
# `Ractor::IsolationError`. Touching it here forces
|
|
854
|
-
# the (shareable) registry into the class-ivar cache
|
|
855
|
-
# before any worker reads.
|
|
856
|
-
Environment::ClassRegistry.default
|
|
857
|
-
|
|
858
|
-
# ADR-15 Phase 4b.x — pre-warm the RBS cache so
|
|
859
|
-
# workers serve every reflection query from the
|
|
860
|
-
# Marshal blob on disk. Without this, the first
|
|
861
|
-
# cache MISS inside a worker falls through to
|
|
862
|
-
# `RBS::EnvironmentLoader.new`, which reads a chain
|
|
863
|
-
# of non-`Ractor.shareable?` RubyGems / RBS module
|
|
864
|
-
# constants and raises `Ractor::IsolationError`.
|
|
865
|
-
# Pre-warming requires a `cache_store`; the run aborts
|
|
866
|
-
# to sequential mode otherwise. See ADR-15 Phase 4b.x
|
|
867
|
-
# for the full chain of failing constants.
|
|
868
|
-
if @cache_store.nil?
|
|
869
|
-
return analyze_files_sequentially_fallback(
|
|
870
|
-
files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
|
|
871
|
-
)
|
|
872
|
-
end
|
|
873
|
-
prewarm_rbs_cache_for_pool
|
|
874
|
-
|
|
875
|
-
configuration = @configuration
|
|
876
|
-
cache_root = @cache_store&.root
|
|
877
|
-
blueprints = @plugin_registry.blueprints
|
|
878
|
-
explain = @explain
|
|
879
|
-
# ADR-32 WD4 — the full project file list travels into
|
|
880
|
-
# every Ractor worker so each worker's WorkerSession
|
|
881
|
-
# can invoke loaded plugins' source_rbs_synthesizers at
|
|
882
|
-
# env-build time. The list is a frozen Array<String>;
|
|
883
|
-
# cheaply shareable.
|
|
884
|
-
shareable_source_files = files.map { |path| path.to_s.dup.freeze }.freeze
|
|
885
|
-
|
|
886
|
-
pool = Array.new(@workers) do
|
|
887
|
-
Ractor.new(configuration, cache_root, blueprints, explain, shareable_source_files) do |configuration, cache_root, blueprints, explain, shareable_source_files| # rubocop:disable Layout/LineLength
|
|
888
|
-
cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
|
|
889
|
-
session = Rigor::Analysis::WorkerSession.new(
|
|
890
|
-
configuration: configuration,
|
|
891
|
-
cache_store: cache_store,
|
|
892
|
-
plugin_blueprints: blueprints,
|
|
893
|
-
explain: explain,
|
|
894
|
-
source_files: shareable_source_files
|
|
895
|
-
)
|
|
896
|
-
main = Ractor.main
|
|
897
|
-
main.send([:prepare, session.prepare_diagnostics])
|
|
898
|
-
|
|
899
|
-
loop do
|
|
900
|
-
msg = Ractor.receive
|
|
901
|
-
break if msg.nil?
|
|
902
|
-
|
|
903
|
-
main.send([:file, msg, session.analyze(msg)])
|
|
904
|
-
end
|
|
905
|
-
|
|
906
|
-
main.send([:done, session.drain_reporters])
|
|
907
|
-
end
|
|
908
|
-
end
|
|
909
|
-
|
|
910
|
-
files.each_with_index { |path, index| pool[index % pool.size].send(path) }
|
|
911
|
-
pool.each { |worker| worker.send(nil) }
|
|
912
|
-
|
|
913
|
-
prepare_diagnostics = nil
|
|
914
|
-
results_by_path = {}
|
|
915
|
-
done_count = 0
|
|
916
|
-
|
|
917
|
-
while done_count < pool.size
|
|
918
|
-
message = Ractor.receive
|
|
919
|
-
case message.first
|
|
920
|
-
when :prepare
|
|
921
|
-
prepare_diagnostics ||= message.last
|
|
922
|
-
when :file
|
|
923
|
-
results_by_path[message[1]] = message[2]
|
|
924
|
-
when :done
|
|
925
|
-
merge_worker_reporters(message.last)
|
|
926
|
-
done_count += 1
|
|
927
|
-
end
|
|
928
|
-
end
|
|
929
|
-
|
|
930
|
-
pool.each(&:join)
|
|
931
|
-
|
|
932
|
-
Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
933
|
-
end
|
|
934
|
-
|
|
935
|
-
# ADR-15 Amendment (2026-05-20) — fork-based worker pool, the
|
|
936
|
-
# active backend for `workers > 0`. Builds ONE {WorkerSession}
|
|
937
|
-
# on the parent, then `fork`s N children that copy-on-write
|
|
938
|
-
# inherit it. Each child analyses a contiguous slice of `files`
|
|
939
|
-
# and writes a Marshal'd `{results:, reporters:}` payload to a
|
|
940
|
-
# temp file; the parent `Process.wait`s every child, merges the
|
|
941
|
-
# payloads, and re-orders diagnostics by original path order.
|
|
942
|
-
#
|
|
943
|
-
# Separate processes have separate GC heaps and `vm->ci_table`
|
|
944
|
-
# (immune to Ruby Bug #22075) and copy-on-write-inherit every
|
|
945
|
-
# constant (no `Ractor.shareable?` constraint). See the ADR-15
|
|
946
|
-
# Amendment + docs/notes/20260520-ractor-pool-cruby-uaf.md.
|
|
947
|
-
#
|
|
948
|
-
# A child that exits non-zero (crash / unmarshalable payload) is
|
|
949
|
-
# degraded: the parent re-analyses that slice in-process and
|
|
950
|
-
# prepends a `pool-degraded` warning.
|
|
951
|
-
def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize
|
|
952
|
-
Environment::ClassRegistry.default
|
|
953
|
-
|
|
954
|
-
session = WorkerSession.new(
|
|
562
|
+
snapshots: @snapshots,
|
|
563
|
+
plugin_registry: -> { @plugin_registry },
|
|
564
|
+
dependency_source_index: -> { @dependency_source_index },
|
|
565
|
+
synthetic_method_index: -> { @synthetic_method_index },
|
|
566
|
+
project_patched_methods: -> { @project_patched_methods },
|
|
567
|
+
project_scope_seed: -> { project_scope_seed_tables },
|
|
568
|
+
analyze_file: ->(path, environment) { analyze_file(path, environment) }
|
|
569
|
+
)
|
|
570
|
+
@diagnostic_aggregator = DiagnosticAggregator.new(
|
|
955
571
|
configuration: @configuration,
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
572
|
+
rbs_extended_reporter: @rbs_extended_reporter,
|
|
573
|
+
boundary_cross_reporter: @boundary_cross_reporter,
|
|
574
|
+
source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
|
|
575
|
+
plugin_registry: -> { @plugin_registry },
|
|
576
|
+
dependency_source_index: -> { @dependency_source_index },
|
|
577
|
+
pool_mode: -> { pool_mode? },
|
|
578
|
+
cached_plugin_prepare_diagnostics: -> { @cached_plugin_prepare_diagnostics },
|
|
579
|
+
pre_eval_diagnostics_from_scanner: -> { @pre_eval_diagnostics_from_scanner },
|
|
580
|
+
synthesized_namespaces_snapshot: -> { @snapshots.synthesized_namespaces },
|
|
581
|
+
conformance_results_snapshot: -> { @snapshots.conformance_results }
|
|
962
582
|
)
|
|
963
|
-
# Force the full RBS load on the parent so children
|
|
964
|
-
# copy-on-write inherit a warm Environment rather than each
|
|
965
|
-
# rebuilding it after the fork.
|
|
966
|
-
session.environment.rbs_loader&.prewarm
|
|
967
|
-
snapshot_fork_pool_stats(session) if @collect_stats
|
|
968
|
-
|
|
969
|
-
worker_count = [@workers, files.size].min
|
|
970
|
-
slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
|
|
971
|
-
results_by_path = {}
|
|
972
|
-
|
|
973
|
-
degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
|
|
974
|
-
children = slices.each_with_index.map do |slice, index|
|
|
975
|
-
out_path = File.join(tmpdir, "worker-#{index}")
|
|
976
|
-
{ pid: fork { run_fork_worker(session, slice, out_path) },
|
|
977
|
-
slice: slice, out_path: out_path }
|
|
978
|
-
end
|
|
979
|
-
collect_fork_results(children, results_by_path)
|
|
980
|
-
end
|
|
981
|
-
|
|
982
|
-
unless degraded.empty?
|
|
983
|
-
degraded.each { |path| results_by_path[path] = session.analyze(path) }
|
|
984
|
-
merge_worker_reporters(session.drain_reporters)
|
|
985
|
-
end
|
|
986
|
-
|
|
987
|
-
diagnostics = Array(session.prepare_diagnostics) +
|
|
988
|
-
files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
989
|
-
degraded.empty? ? diagnostics : diagnostics.unshift(fork_degraded_diagnostic(degraded.size))
|
|
990
583
|
end
|
|
991
584
|
|
|
992
|
-
#
|
|
993
|
-
#
|
|
994
|
-
#
|
|
995
|
-
#
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
exit!(0)
|
|
1001
|
-
rescue StandardError
|
|
1002
|
-
exit!(1)
|
|
1003
|
-
end
|
|
1004
|
-
|
|
1005
|
-
# Snapshots `class_decl_paths` from the parent session's loader
|
|
1006
|
-
# so end-of-run {RunStats} can attribute the RBS class universe.
|
|
1007
|
-
def snapshot_fork_pool_stats(session)
|
|
1008
|
-
loader = session.environment.rbs_loader
|
|
1009
|
-
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
1010
|
-
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
1011
|
-
end
|
|
1012
|
-
|
|
1013
|
-
# Waits for every forked child, merges each successful payload
|
|
1014
|
-
# into `results_by_path`, and returns the file paths whose
|
|
1015
|
-
# worker exited abnormally (for in-process degrade).
|
|
1016
|
-
def collect_fork_results(children, results_by_path)
|
|
1017
|
-
degraded = []
|
|
1018
|
-
children.each do |child|
|
|
1019
|
-
_, status = Process.waitpid2(child[:pid])
|
|
1020
|
-
payload = fork_worker_payload(status, child[:out_path])
|
|
1021
|
-
if payload
|
|
1022
|
-
results_by_path.merge!(payload.fetch(:results))
|
|
1023
|
-
merge_worker_reporters(payload.fetch(:reporters))
|
|
1024
|
-
else
|
|
1025
|
-
degraded.concat(child[:slice])
|
|
1026
|
-
end
|
|
1027
|
-
end
|
|
1028
|
-
degraded
|
|
1029
|
-
end
|
|
1030
|
-
|
|
1031
|
-
# @return [Hash, nil] the child's `{results:, reporters:}`
|
|
1032
|
-
# payload, or nil when the child exited abnormally or wrote no
|
|
1033
|
-
# readable payload. `Marshal.load` is safe here: the blob was
|
|
1034
|
-
# written by our own forked child to a temp file we created.
|
|
1035
|
-
def fork_worker_payload(status, out_path)
|
|
1036
|
-
return nil unless status.success? && File.exist?(out_path)
|
|
1037
|
-
|
|
1038
|
-
Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
|
|
1039
|
-
rescue StandardError
|
|
1040
|
-
nil
|
|
1041
|
-
end
|
|
1042
|
-
|
|
1043
|
-
def fork_degraded_diagnostic(count)
|
|
1044
|
-
Diagnostic.new(
|
|
1045
|
-
path: ".rigor.yml", line: 1, column: 1,
|
|
1046
|
-
message: "fork pool degraded: #{count} file(s) re-analysed in-process " \
|
|
1047
|
-
"after a worker exited abnormally",
|
|
1048
|
-
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
1049
|
-
)
|
|
585
|
+
# ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`.
|
|
586
|
+
# Editor mode (`buffer:` non-nil) silently overrides pool
|
|
587
|
+
# mode to sequential. The real decision lives on
|
|
588
|
+
# {PoolCoordinator}; the predicate stays on the runner because
|
|
589
|
+
# `run_result_cacheable?` consults it (and a spec exercises it
|
|
590
|
+
# via `send`).
|
|
591
|
+
def pool_mode?
|
|
592
|
+
@pool_coordinator.pool_mode?
|
|
1050
593
|
end
|
|
1051
594
|
|
|
1052
595
|
# End-of-run telemetry. Walks the cached
|
|
@@ -1060,10 +603,10 @@ module Rigor
|
|
|
1060
603
|
# Wall + RSS are single syscalls; total cost is bounded
|
|
1061
604
|
# by the snapshot size (~1000-2000 entries).
|
|
1062
605
|
def build_run_stats(wall_started_at:, expansion:)
|
|
1063
|
-
snapshot = @
|
|
606
|
+
snapshot = @snapshots.class_decl_paths
|
|
1064
607
|
project_sig, bundled = RunStats.partition_classes(
|
|
1065
608
|
class_decl_paths: snapshot,
|
|
1066
|
-
signature_paths: @
|
|
609
|
+
signature_paths: @snapshots.signature_paths
|
|
1067
610
|
)
|
|
1068
611
|
RunStats.new(
|
|
1069
612
|
wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
|
|
@@ -1078,612 +621,6 @@ module Rigor
|
|
|
1078
621
|
)
|
|
1079
622
|
end
|
|
1080
623
|
|
|
1081
|
-
# ADR-15 Phase 4b.x — drives every cached RBS producer
|
|
1082
|
-
# on the main Ractor so each worker can serve all
|
|
1083
|
-
# reflection queries from disk (Marshal-load only).
|
|
1084
|
-
# Builds a single coordinator-side {Environment} for
|
|
1085
|
-
# this purpose; the env object is discarded immediately
|
|
1086
|
-
# after the cache is warm — workers build their own
|
|
1087
|
-
# `Environment.for_project` inside the Ractor body,
|
|
1088
|
-
# which then routes through `cached_env` instead of
|
|
1089
|
-
# `RBS::EnvironmentLoader.new`.
|
|
1090
|
-
def prewarm_rbs_cache_for_pool
|
|
1091
|
-
warm_env = Environment.for_project(
|
|
1092
|
-
libraries: @configuration.libraries,
|
|
1093
|
-
signature_paths: @configuration.signature_paths,
|
|
1094
|
-
cache_store: @cache_store,
|
|
1095
|
-
bundler_bundle_path: @configuration.bundler_bundle_path,
|
|
1096
|
-
bundler_auto_detect: @configuration.bundler_auto_detect,
|
|
1097
|
-
bundler_lockfile: @configuration.bundler_lockfile,
|
|
1098
|
-
rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
|
|
1099
|
-
rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
|
|
1100
|
-
)
|
|
1101
|
-
warm_env.rbs_loader&.prewarm
|
|
1102
|
-
end
|
|
1103
|
-
|
|
1104
|
-
# ADR-15 Phase 4b.x — pool-mode safety net. When pool
|
|
1105
|
-
# mode is configured but a precondition fails (currently:
|
|
1106
|
-
# `--no-cache` would force workers through
|
|
1107
|
-
# `EnvironmentLoader.new`), degrade to sequential
|
|
1108
|
-
# analysis with a `:warning` `pool-degraded` diagnostic
|
|
1109
|
-
# at run start. The actual per-file analysis runs on
|
|
1110
|
-
# the coordinator, identical to the default sequential
|
|
1111
|
-
# path.
|
|
1112
|
-
def analyze_files_sequentially_fallback(files, reason:)
|
|
1113
|
-
environment = build_runner_environment
|
|
1114
|
-
diagnostics = files.flat_map { |path| analyze_file(path, environment) }
|
|
1115
|
-
loader = environment.rbs_loader
|
|
1116
|
-
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
1117
|
-
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
1118
|
-
diagnostics.unshift(
|
|
1119
|
-
Diagnostic.new(
|
|
1120
|
-
path: ".rigor.yml", line: 1, column: 1,
|
|
1121
|
-
message: "pool mode degraded to sequential: #{reason}",
|
|
1122
|
-
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
1123
|
-
)
|
|
1124
|
-
)
|
|
1125
|
-
end
|
|
1126
|
-
|
|
1127
|
-
def merge_worker_reporters(drained)
|
|
1128
|
-
rbs = drained.fetch(:rbs_extended)
|
|
1129
|
-
rbs.fetch(:unresolved_payloads).each do |entry|
|
|
1130
|
-
@rbs_extended_reporter.record_unresolved(
|
|
1131
|
-
payload: entry.payload, source_location: entry.source_location
|
|
1132
|
-
)
|
|
1133
|
-
end
|
|
1134
|
-
rbs.fetch(:lossy_projections).each do |entry|
|
|
1135
|
-
@rbs_extended_reporter.record_lossy_projection(
|
|
1136
|
-
head: entry.head, source_location: entry.source_location
|
|
1137
|
-
)
|
|
1138
|
-
end
|
|
1139
|
-
drained.fetch(:boundary_cross).each do |entry|
|
|
1140
|
-
@boundary_cross_reporter.record(
|
|
1141
|
-
class_name: entry.class_name,
|
|
1142
|
-
method_name: entry.method_name,
|
|
1143
|
-
gem_name: entry.gem_name,
|
|
1144
|
-
rbs_display: entry.rbs_display
|
|
1145
|
-
)
|
|
1146
|
-
end
|
|
1147
|
-
# ADR-32 WD6 — merge per-worker synthesizer failures
|
|
1148
|
-
# back into the coordinator's reporter. Fetched with a
|
|
1149
|
-
# default empty array so older drains (pre-slice-2)
|
|
1150
|
-
# remain compatible.
|
|
1151
|
-
Array(drained[:source_rbs_synthesis]).each do |entry|
|
|
1152
|
-
@source_rbs_synthesis_reporter.record(
|
|
1153
|
-
plugin_id: entry.plugin_id, path: entry.path, message: entry.message
|
|
1154
|
-
)
|
|
1155
|
-
end
|
|
1156
|
-
end
|
|
1157
|
-
|
|
1158
|
-
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
1159
|
-
# and returns the resulting {Rigor::Plugin::Registry}. Loader
|
|
1160
|
-
# failures are isolated: each surfaces as a `:plugin_loader`
|
|
1161
|
-
# diagnostic on the run's `Result` rather than aborting the
|
|
1162
|
-
# analysis. Plugins that load successfully but contribute no
|
|
1163
|
-
# protocol hooks are inert in slice 1; later v0.1.0 slices
|
|
1164
|
-
# wire the contribution merger through this registry.
|
|
1165
|
-
def load_plugins
|
|
1166
|
-
return Plugin::Registry::EMPTY if @configuration.plugins.empty?
|
|
1167
|
-
|
|
1168
|
-
services = Plugin::Services.new(
|
|
1169
|
-
reflection: Reflection,
|
|
1170
|
-
type: Type::Combinator,
|
|
1171
|
-
configuration: @configuration,
|
|
1172
|
-
cache_store: @cache_store,
|
|
1173
|
-
trust_policy: build_trust_policy
|
|
1174
|
-
)
|
|
1175
|
-
if @plugin_requirer
|
|
1176
|
-
Plugin::Loader.load(configuration: @configuration, services: services, requirer: @plugin_requirer)
|
|
1177
|
-
else
|
|
1178
|
-
Plugin::Loader.load(configuration: @configuration, services: services)
|
|
1179
|
-
end
|
|
1180
|
-
end
|
|
1181
|
-
|
|
1182
|
-
# Builds the {Rigor::Plugin::TrustPolicy} for this run. Trusted
|
|
1183
|
-
# gems are the gem-name half of every entry in
|
|
1184
|
-
# `Configuration#plugins`. Allowed read roots default to the
|
|
1185
|
-
# project root (CWD), the project's signature_paths, and each
|
|
1186
|
-
# trusted gem's `Gem::Specification#full_gem_path`, plus any
|
|
1187
|
-
# extras the user listed under `plugins_io.allowed_paths`.
|
|
1188
|
-
# Slice 2 keeps `network_policy` `:disabled` — the only value
|
|
1189
|
-
# the configuration accepts today.
|
|
1190
|
-
def build_trust_policy
|
|
1191
|
-
trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
|
|
1192
|
-
roots = [Dir.pwd]
|
|
1193
|
-
Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
|
|
1194
|
-
trusted_gems.each do |gem_name|
|
|
1195
|
-
path = trusted_gem_root(gem_name)
|
|
1196
|
-
roots << path if path
|
|
1197
|
-
end
|
|
1198
|
-
@configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
|
|
1199
|
-
|
|
1200
|
-
Plugin::TrustPolicy.new(
|
|
1201
|
-
trusted_gems: trusted_gems,
|
|
1202
|
-
allowed_read_roots: roots,
|
|
1203
|
-
network_policy: @configuration.plugins_io_network,
|
|
1204
|
-
allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
|
|
1205
|
-
)
|
|
1206
|
-
end
|
|
1207
|
-
|
|
1208
|
-
def trusted_gem_name(entry)
|
|
1209
|
-
case entry
|
|
1210
|
-
when String then entry
|
|
1211
|
-
when Hash then entry["gem"] || entry["id"]
|
|
1212
|
-
end
|
|
1213
|
-
end
|
|
1214
|
-
|
|
1215
|
-
def trusted_gem_root(gem_name)
|
|
1216
|
-
return nil if gem_name.nil? || gem_name.empty?
|
|
1217
|
-
|
|
1218
|
-
spec = Gem.loaded_specs[gem_name]
|
|
1219
|
-
spec&.full_gem_path # rigor:disable undefined-method
|
|
1220
|
-
rescue StandardError
|
|
1221
|
-
nil
|
|
1222
|
-
end
|
|
1223
|
-
|
|
1224
|
-
# ADR-8 § "Severity profile" — re-stamps each diagnostic's
|
|
1225
|
-
# severity from the configured profile + per-rule
|
|
1226
|
-
# overrides. Rules emit with their authored severity; the
|
|
1227
|
-
# profile is the final filter. Diagnostics whose resolved
|
|
1228
|
-
# severity is `:off` are dropped from the run result.
|
|
1229
|
-
def apply_severity_profile(diagnostics)
|
|
1230
|
-
diagnostics.filter_map { |diagnostic| stamp_severity(diagnostic) }
|
|
1231
|
-
end
|
|
1232
|
-
|
|
1233
|
-
def stamp_severity(diagnostic)
|
|
1234
|
-
return diagnostic if diagnostic.rule.nil?
|
|
1235
|
-
|
|
1236
|
-
resolved = Configuration::SeverityProfile.resolve(
|
|
1237
|
-
rule: diagnostic.rule,
|
|
1238
|
-
authored_severity: diagnostic.severity,
|
|
1239
|
-
profile: @configuration.severity_profile,
|
|
1240
|
-
overrides: @configuration.severity_overrides
|
|
1241
|
-
)
|
|
1242
|
-
return nil if resolved == :off
|
|
1243
|
-
return diagnostic if resolved == diagnostic.severity
|
|
1244
|
-
|
|
1245
|
-
Diagnostic.new(
|
|
1246
|
-
path: diagnostic.path,
|
|
1247
|
-
line: diagnostic.line,
|
|
1248
|
-
column: diagnostic.column,
|
|
1249
|
-
message: diagnostic.message,
|
|
1250
|
-
severity: resolved,
|
|
1251
|
-
rule: diagnostic.rule,
|
|
1252
|
-
source_family: diagnostic.source_family
|
|
1253
|
-
)
|
|
1254
|
-
end
|
|
1255
|
-
|
|
1256
|
-
def plugin_load_diagnostics
|
|
1257
|
-
@plugin_registry.load_errors.map do |error|
|
|
1258
|
-
Diagnostic.new(
|
|
1259
|
-
path: ".rigor.yml",
|
|
1260
|
-
line: 1,
|
|
1261
|
-
column: 1,
|
|
1262
|
-
message: error.message,
|
|
1263
|
-
severity: :error,
|
|
1264
|
-
rule: "load-error",
|
|
1265
|
-
source_family: :plugin_loader
|
|
1266
|
-
)
|
|
1267
|
-
end
|
|
1268
|
-
end
|
|
1269
|
-
|
|
1270
|
-
# ADR-10 § "Diagnostic prefix family" — surfaces gems
|
|
1271
|
-
# listed in `dependencies.source_inference` that RubyGems
|
|
1272
|
-
# could not resolve. The run continues; the gem simply
|
|
1273
|
-
# contributes nothing this session, mirroring the
|
|
1274
|
-
# plugin-load error envelope. Authored `:warning` because
|
|
1275
|
-
# an unresolvable gem usually means a typo or a missing
|
|
1276
|
-
# `bundle install` rather than a project-blocking problem;
|
|
1277
|
-
# the severity profile still re-stamps it.
|
|
1278
|
-
def dependency_source_diagnostics
|
|
1279
|
-
@dependency_source_index.unresolvable.map do |entry|
|
|
1280
|
-
Diagnostic.new(
|
|
1281
|
-
path: ".rigor.yml",
|
|
1282
|
-
line: 1,
|
|
1283
|
-
column: 1,
|
|
1284
|
-
message: "dependencies.source_inference[].gem #{entry.gem_name.inspect} could not be " \
|
|
1285
|
-
"resolved (#{entry.reason}); skipping",
|
|
1286
|
-
severity: :warning,
|
|
1287
|
-
rule: "dynamic.dependency-source.gem-not-found",
|
|
1288
|
-
source_family: :builtin
|
|
1289
|
-
)
|
|
1290
|
-
end
|
|
1291
|
-
end
|
|
1292
|
-
|
|
1293
|
-
# ADR-10 § "Budget interaction" / slice 4 — emits one
|
|
1294
|
-
# `:warning` per gem whose Walker run hit the
|
|
1295
|
-
# `dependencies.budget_per_gem` cap. The cap is a Walker-
|
|
1296
|
-
# side guard rail (slice 4 picks the (α) semantics from
|
|
1297
|
-
# ADR-10 WD4: harvesting stops, the dispatcher behaves
|
|
1298
|
-
# exactly as before for unrecorded methods). The
|
|
1299
|
-
# diagnostic names the gem and points the user at the
|
|
1300
|
-
# three remediations: ship RBS, reduce `mode:` from
|
|
1301
|
-
# `full` to `when_missing`, or de-list the gem.
|
|
1302
|
-
# ADR-10 § "config-conflict diagnostic" / 5d — surfaces
|
|
1303
|
-
# `Configuration::Dependencies` warnings accumulated
|
|
1304
|
-
# during `from_h` deduplication of the `includes:`-chain
|
|
1305
|
-
# source_inference array. Each warning describes a
|
|
1306
|
-
# per-gem mode conflict that the merge resolved
|
|
1307
|
-
# right-wins; the user sees one diagnostic per conflict.
|
|
1308
|
-
# `:warning` matches the user's "warn but don't block"
|
|
1309
|
-
# preference per the design discussion.
|
|
1310
|
-
def dependency_source_config_conflict_diagnostics
|
|
1311
|
-
@configuration.dependencies.warnings.map do |message|
|
|
1312
|
-
Diagnostic.new(
|
|
1313
|
-
path: ".rigor.yml",
|
|
1314
|
-
line: 1,
|
|
1315
|
-
column: 1,
|
|
1316
|
-
message: message,
|
|
1317
|
-
severity: :warning,
|
|
1318
|
-
rule: "dynamic.dependency-source.config-conflict",
|
|
1319
|
-
source_family: :builtin
|
|
1320
|
-
)
|
|
1321
|
-
end
|
|
1322
|
-
end
|
|
1323
|
-
|
|
1324
|
-
def dependency_source_budget_diagnostics
|
|
1325
|
-
budget = @configuration.dependencies.budget_per_gem
|
|
1326
|
-
@dependency_source_index.budget_exceeded.map do |gem_name|
|
|
1327
|
-
Diagnostic.new(
|
|
1328
|
-
path: ".rigor.yml",
|
|
1329
|
-
line: 1,
|
|
1330
|
-
column: 1,
|
|
1331
|
-
message: "dependencies.source_inference[].gem #{gem_name.inspect} exceeded the per-gem " \
|
|
1332
|
-
"catalog cap (#{budget} method definitions); the remaining methods fall back " \
|
|
1333
|
-
"to the existing RBS-or-Dynamic[top] boundary. Ship RBS for the gem, set " \
|
|
1334
|
-
"`mode: when_missing` instead of `full`, or de-list the gem.",
|
|
1335
|
-
severity: :warning,
|
|
1336
|
-
rule: "dynamic.dependency-source.budget-exceeded",
|
|
1337
|
-
source_family: :builtin
|
|
1338
|
-
)
|
|
1339
|
-
end
|
|
1340
|
-
end
|
|
1341
|
-
|
|
1342
|
-
# O4 Layer 3 slice 3 — graceful-degradation coverage
|
|
1343
|
-
# report. When the project has a `Gemfile.lock` (slice 1)
|
|
1344
|
-
# and one or more locked gems are not covered by ANY of
|
|
1345
|
-
# the four RBS resolution paths (`DEFAULT_LIBRARIES`,
|
|
1346
|
-
# `data/vendored_gem_sigs/`, slice-1 bundle-shipped
|
|
1347
|
-
# `sig/`, slice-2 `rbs_collection.lock.yaml`), emit a
|
|
1348
|
-
# single `:info` diagnostic summarising the uncovered set
|
|
1349
|
-
# so the user can act on it (run `rbs collection install`,
|
|
1350
|
-
# opt the gem into `dependencies.source_inference:`, or
|
|
1351
|
-
# accept the `Dynamic[T]` fallback).
|
|
1352
|
-
#
|
|
1353
|
-
# Suppressed when the lockfile is empty, when every gem
|
|
1354
|
-
# is covered, or when slice 1's `bundler.lockfile`
|
|
1355
|
-
# discovery returned nothing (no lockfile to read).
|
|
1356
|
-
def rbs_coverage_diagnostics
|
|
1357
|
-
locked = Environment::LockfileResolver.locked_gems(
|
|
1358
|
-
lockfile_path: @configuration.bundler_lockfile,
|
|
1359
|
-
project_root: Dir.pwd,
|
|
1360
|
-
auto_detect: @configuration.bundler_auto_detect
|
|
1361
|
-
)
|
|
1362
|
-
return [] if locked.empty?
|
|
1363
|
-
|
|
1364
|
-
bundle_sig_paths = Environment::BundleSigDiscovery.discover(
|
|
1365
|
-
bundle_path: @configuration.bundler_bundle_path,
|
|
1366
|
-
project_root: Dir.pwd,
|
|
1367
|
-
auto_detect: @configuration.bundler_auto_detect,
|
|
1368
|
-
locked_gems: locked
|
|
1369
|
-
)
|
|
1370
|
-
collection_paths = Environment::RbsCollectionDiscovery.discover(
|
|
1371
|
-
lockfile_path: @configuration.rbs_collection_lockfile,
|
|
1372
|
-
project_root: Dir.pwd,
|
|
1373
|
-
auto_detect: @configuration.rbs_collection_auto_detect
|
|
1374
|
-
)
|
|
1375
|
-
rows = Environment::RbsCoverageReport.classify(
|
|
1376
|
-
locked_gems: locked,
|
|
1377
|
-
default_libraries: Environment::DEFAULT_LIBRARIES,
|
|
1378
|
-
bundle_sig_paths: bundle_sig_paths,
|
|
1379
|
-
rbs_collection_paths: collection_paths
|
|
1380
|
-
)
|
|
1381
|
-
missing = Environment::RbsCoverageReport.missing(rows)
|
|
1382
|
-
return [] if missing.empty?
|
|
1383
|
-
|
|
1384
|
-
[build_rbs_coverage_missing_diagnostic(missing)]
|
|
1385
|
-
end
|
|
1386
|
-
|
|
1387
|
-
# Robustness uplift companion (ADR-5) — when the project's
|
|
1388
|
-
# `signature_paths:` RBS declared qualified names without their
|
|
1389
|
-
# enclosing namespace, `RbsLoader` synthesizes the missing
|
|
1390
|
-
# `module`s so the otherwise-inert signatures resolve. Surface a
|
|
1391
|
-
# single `:info` diagnostic naming them so the user knows their
|
|
1392
|
-
# sig set is malformed (`rbs validate` rejects it) and can fix it
|
|
1393
|
-
# at the source. Authored `:info`: the analysis already succeeded;
|
|
1394
|
-
# this is advisory, never a gate. Empty for a well-formed sig set.
|
|
1395
|
-
def rbs_synthesized_namespace_diagnostics
|
|
1396
|
-
synthesized = @synthesized_namespaces_snapshot
|
|
1397
|
-
return [] if synthesized.nil? || synthesized.empty?
|
|
1398
|
-
|
|
1399
|
-
[build_rbs_synthesized_namespace_diagnostic(synthesized)]
|
|
1400
|
-
end
|
|
1401
|
-
|
|
1402
|
-
# Maps the per-run `rigor:v1:conforms-to` scan results into
|
|
1403
|
-
# diagnostics (spec: `rbs-extended.md` § "Explicit conformance
|
|
1404
|
-
# directive"). A class that declares `conforms-to _Interface`
|
|
1405
|
-
# but is missing a required interface method surfaces as
|
|
1406
|
-
# `rbs_extended.unsatisfied-conformance`; an unresolvable
|
|
1407
|
-
# interface name surfaces as `dynamic.rbs-extended.unresolved`
|
|
1408
|
-
# `:info` (the same fail-soft channel the other directive
|
|
1409
|
-
# parsers use). Empty for a project with no directive, a
|
|
1410
|
-
# well-formed conformance, or a non-sequential pool run (the
|
|
1411
|
-
# snapshot mirrors `synthesized_namespaces`).
|
|
1412
|
-
def conforms_to_diagnostics
|
|
1413
|
-
results = @conformance_results_snapshot
|
|
1414
|
-
return [] if results.nil? || results.empty?
|
|
1415
|
-
|
|
1416
|
-
results.map { |record| build_conformance_diagnostic(record) }
|
|
1417
|
-
end
|
|
1418
|
-
|
|
1419
|
-
def build_conformance_diagnostic(record)
|
|
1420
|
-
case record
|
|
1421
|
-
when RbsExtended::ConformanceChecker::Unsatisfied
|
|
1422
|
-
build_unsatisfied_conformance_diagnostic(record)
|
|
1423
|
-
when RbsExtended::ConformanceChecker::IncompatibleSignature
|
|
1424
|
-
build_incompatible_signature_diagnostic(record)
|
|
1425
|
-
else # UnresolvedInterface
|
|
1426
|
-
build_reporter_diagnostic(
|
|
1427
|
-
record.location,
|
|
1428
|
-
rule: "dynamic.rbs-extended.unresolved",
|
|
1429
|
-
message: "`#{record.class_name}` declares `conforms-to #{record.interface_name}` but " \
|
|
1430
|
-
"interface `#{record.interface_name}` is not loaded. Check for a typo or add " \
|
|
1431
|
-
"the `sig`/library that declares it to the RBS load path."
|
|
1432
|
-
)
|
|
1433
|
-
end
|
|
1434
|
-
end
|
|
1435
|
-
|
|
1436
|
-
def build_unsatisfied_conformance_diagnostic(record)
|
|
1437
|
-
path, line, column = location_fields(record.location)
|
|
1438
|
-
Diagnostic.new(
|
|
1439
|
-
path: path, line: line, column: column,
|
|
1440
|
-
message: "`#{record.class_name}` declares `conforms-to #{record.interface_name}` " \
|
|
1441
|
-
"but does not provide #{pluralize_methods(record.missing_methods)}: " \
|
|
1442
|
-
"#{record.missing_methods.map { |m| "`##{m}`" }.join(', ')}. Implement the " \
|
|
1443
|
-
"missing method(s) or remove the directive.",
|
|
1444
|
-
severity: :warning,
|
|
1445
|
-
rule: "rbs_extended.unsatisfied-conformance",
|
|
1446
|
-
source_family: :builtin
|
|
1447
|
-
)
|
|
1448
|
-
end
|
|
1449
|
-
|
|
1450
|
-
def build_incompatible_signature_diagnostic(record)
|
|
1451
|
-
path, line, column = location_fields(record.location)
|
|
1452
|
-
Diagnostic.new(
|
|
1453
|
-
path: path, line: line, column: column,
|
|
1454
|
-
message: "`#{record.class_name}##{record.method_name}` does not satisfy " \
|
|
1455
|
-
"`conforms-to #{record.interface_name}`: #{record.detail}. Adjust the " \
|
|
1456
|
-
"signature to a subtype of the interface contract.",
|
|
1457
|
-
severity: :warning,
|
|
1458
|
-
rule: "rbs_extended.unsatisfied-conformance",
|
|
1459
|
-
source_family: :builtin,
|
|
1460
|
-
method_name: record.method_name
|
|
1461
|
-
)
|
|
1462
|
-
end
|
|
1463
|
-
|
|
1464
|
-
def pluralize_methods(methods)
|
|
1465
|
-
methods.size == 1 ? "required method" : "#{methods.size} required methods"
|
|
1466
|
-
end
|
|
1467
|
-
|
|
1468
|
-
# True when the project declares its own `signature_paths:` (the
|
|
1469
|
-
# only place the qualified-name-without-namespace mistake lives).
|
|
1470
|
-
def project_signature_paths?
|
|
1471
|
-
paths = @configuration.signature_paths
|
|
1472
|
-
!(paths.nil? || paths.empty?)
|
|
1473
|
-
end
|
|
1474
|
-
|
|
1475
|
-
def build_rbs_synthesized_namespace_diagnostic(synthesized)
|
|
1476
|
-
sample_size = 5
|
|
1477
|
-
sample = synthesized.first(sample_size)
|
|
1478
|
-
suffix = synthesized.size > sample_size ? ", and #{synthesized.size - sample_size} more" : ""
|
|
1479
|
-
Diagnostic.new(
|
|
1480
|
-
path: ".rigor.yml",
|
|
1481
|
-
line: 1,
|
|
1482
|
-
column: 1,
|
|
1483
|
-
message: "#{synthesized.size} RBS namespace(s) under `signature_paths:` are " \
|
|
1484
|
-
"referenced by qualified declarations (e.g. `class Foo::Bar`) but never " \
|
|
1485
|
-
"declared: #{sample.join(', ')}#{suffix}. `rbs validate` rejects this; " \
|
|
1486
|
-
"Rigor synthesized the missing `module`(s) so the signatures still " \
|
|
1487
|
-
"resolve. Declare each (`module <name>` / `class <name>`) in your RBS to " \
|
|
1488
|
-
"make the sig set valid upstream.",
|
|
1489
|
-
severity: :info,
|
|
1490
|
-
rule: "rbs.coverage.synthesized-namespace",
|
|
1491
|
-
source_family: :builtin
|
|
1492
|
-
)
|
|
1493
|
-
end
|
|
1494
|
-
|
|
1495
|
-
def build_rbs_coverage_missing_diagnostic(missing)
|
|
1496
|
-
sample_size = 5
|
|
1497
|
-
sample = missing.first(sample_size).map(&:gem_name)
|
|
1498
|
-
suffix = missing.size > sample_size ? ", and #{missing.size - sample_size} more" : ""
|
|
1499
|
-
Diagnostic.new(
|
|
1500
|
-
path: ".rigor.yml",
|
|
1501
|
-
line: 1,
|
|
1502
|
-
column: 1,
|
|
1503
|
-
message: "#{missing.size} gem(s) in Gemfile.lock have no RBS available: " \
|
|
1504
|
-
"#{sample.join(', ')}#{suffix}. " \
|
|
1505
|
-
"Consider `rbs collection install` to fetch community RBS from " \
|
|
1506
|
-
"`ruby/gem_rbs_collection`, ship `sig/` in the gem itself, or " \
|
|
1507
|
-
"opt the gem into `dependencies.source_inference:` in `.rigor.yml`.",
|
|
1508
|
-
severity: :info,
|
|
1509
|
-
rule: "rbs.coverage.missing-gem",
|
|
1510
|
-
source_family: :builtin
|
|
1511
|
-
)
|
|
1512
|
-
end
|
|
1513
|
-
|
|
1514
|
-
# ADR-13 slice 3b — drains the per-run
|
|
1515
|
-
# {RbsExtended::Reporter} into one diagnostic per accumulated
|
|
1516
|
-
# event:
|
|
1517
|
-
#
|
|
1518
|
-
# - `dynamic.rbs-extended.unresolved` for every annotation
|
|
1519
|
-
# payload the parser could not turn into a {Rigor::Type}.
|
|
1520
|
-
# Surfaces typos and references to plugin-supplied names
|
|
1521
|
-
# the project did not enable.
|
|
1522
|
-
# - `dynamic.shape.lossy-projection` for every shape-projection
|
|
1523
|
-
# type function (`pick_of`, …) applied to a carrier that
|
|
1524
|
-
# loses precision (anything other than `HashShape` / `Tuple`).
|
|
1525
|
-
#
|
|
1526
|
-
# Both are authored `:info`; the severity profile re-stamps
|
|
1527
|
-
# them per project taste. Path / line / column come from the
|
|
1528
|
-
# annotation's `RBS::Location` when available, falling back
|
|
1529
|
-
# to `.rigor.yml`-style file-level attribution otherwise.
|
|
1530
|
-
def rbs_extended_reporter_diagnostics
|
|
1531
|
-
return [] if @rbs_extended_reporter.empty?
|
|
1532
|
-
|
|
1533
|
-
unresolved = @rbs_extended_reporter.unresolved_payloads.map do |entry|
|
|
1534
|
-
build_reporter_diagnostic(
|
|
1535
|
-
entry.source_location,
|
|
1536
|
-
rule: "dynamic.rbs-extended.unresolved",
|
|
1537
|
-
message: "`RBS::Extended` directive payload could not be resolved: " \
|
|
1538
|
-
"#{entry.payload.inspect}. Check for typos or enable a plugin " \
|
|
1539
|
-
"that contributes the referenced type vocabulary."
|
|
1540
|
-
)
|
|
1541
|
-
end
|
|
1542
|
-
|
|
1543
|
-
lossy = @rbs_extended_reporter.lossy_projections.map do |entry|
|
|
1544
|
-
build_reporter_diagnostic(
|
|
1545
|
-
entry.source_location,
|
|
1546
|
-
rule: "dynamic.shape.lossy-projection",
|
|
1547
|
-
message: "Shape projection `#{entry.head}` applied to a carrier without a " \
|
|
1548
|
-
"literal shape; the projection degrades to the input type. Author " \
|
|
1549
|
-
"a `HashShape` / `Tuple` carrier or accept the unchanged result."
|
|
1550
|
-
)
|
|
1551
|
-
end
|
|
1552
|
-
|
|
1553
|
-
unresolved + lossy
|
|
1554
|
-
end
|
|
1555
|
-
|
|
1556
|
-
# ADR-10 slice 5c — drains the per-run
|
|
1557
|
-
# {DependencySourceInference::BoundaryCrossReporter} into
|
|
1558
|
-
# `dynamic.dependency-source.boundary-cross` `:info`
|
|
1559
|
-
# diagnostics. Each event flags a call site where RBS
|
|
1560
|
-
# dispatch produced a concrete answer AND a `mode: :full`
|
|
1561
|
-
# opt-in gem's source catalog ALSO contains an entry for
|
|
1562
|
-
# the same `(class_name, method_name)` — i.e., both
|
|
1563
|
-
# contracts have an opinion. RBS still wins on the
|
|
1564
|
-
# dispatch result; the diagnostic is purely advisory so
|
|
1565
|
-
# the user can verify the two contracts haven't drifted.
|
|
1566
|
-
#
|
|
1567
|
-
# Severity profile re-stamps the rule per project taste.
|
|
1568
|
-
# The diagnostic carries no `path` / `line` / `column`
|
|
1569
|
-
# because the crossing is per-method-per-gem, not
|
|
1570
|
-
# per-call-site — the diagnostic anchors at `.rigor.yml`
|
|
1571
|
-
# like the other `dependency-source.*` diagnostics that
|
|
1572
|
-
# report on opt-in configuration.
|
|
1573
|
-
# ADR-32 WD6 — drains the per-run
|
|
1574
|
-
# {Plugin::SourceRbsSynthesisReporter} into
|
|
1575
|
-
# `source-rbs-synthesis-failed` `:info` diagnostics. Each
|
|
1576
|
-
# entry names the plugin that owns the synthesizer, the
|
|
1577
|
-
# source file the rbs-inline parser couldn't process, and
|
|
1578
|
-
# the upstream error message. The synthesizer-emitting
|
|
1579
|
-
# plugin (currently only `rigor-rbs-inline`) treats a
|
|
1580
|
-
# parse failure as a no-contribution event so analysis
|
|
1581
|
-
# continues; this stream surfaces the failure so the user
|
|
1582
|
-
# can see which files contributed nothing and why.
|
|
1583
|
-
#
|
|
1584
|
-
# Severity profile re-stamps the rule per project taste.
|
|
1585
|
-
def source_rbs_synthesis_diagnostics
|
|
1586
|
-
return [] if @source_rbs_synthesis_reporter.empty?
|
|
1587
|
-
|
|
1588
|
-
@source_rbs_synthesis_reporter.entries.map do |entry|
|
|
1589
|
-
Diagnostic.new(
|
|
1590
|
-
path: entry.path, line: 1, column: 1,
|
|
1591
|
-
message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
|
|
1592
|
-
"#{entry.message}. The file's analysis falls back to no inline-RBS " \
|
|
1593
|
-
"contribution. Fix the inline-RBS comment grammar or remove the " \
|
|
1594
|
-
"annotation to silence this diagnostic.",
|
|
1595
|
-
severity: :info,
|
|
1596
|
-
rule: "source-rbs-synthesis-failed",
|
|
1597
|
-
source_family: :builtin
|
|
1598
|
-
)
|
|
1599
|
-
end
|
|
1600
|
-
end
|
|
1601
|
-
|
|
1602
|
-
def boundary_cross_diagnostics
|
|
1603
|
-
return [] if @boundary_cross_reporter.empty?
|
|
1604
|
-
|
|
1605
|
-
@boundary_cross_reporter.entries.map do |entry|
|
|
1606
|
-
Diagnostic.new(
|
|
1607
|
-
path: ".rigor.yml", line: 1, column: 1,
|
|
1608
|
-
message: "`#{entry.class_name}##{entry.method_name}` is contributed by both " \
|
|
1609
|
-
"RBS (#{entry.rbs_display}) and the `mode: :full` opt-in gem " \
|
|
1610
|
-
"`#{entry.gem_name}`. RBS wins on dispatch; verify the gem source " \
|
|
1611
|
-
"has not drifted from its RBS contract.",
|
|
1612
|
-
severity: :info,
|
|
1613
|
-
rule: "dynamic.dependency-source.boundary-cross",
|
|
1614
|
-
source_family: :builtin
|
|
1615
|
-
)
|
|
1616
|
-
end
|
|
1617
|
-
end
|
|
1618
|
-
|
|
1619
|
-
def build_reporter_diagnostic(source_location, rule:, message:)
|
|
1620
|
-
path, line, column = location_fields(source_location)
|
|
1621
|
-
Diagnostic.new(
|
|
1622
|
-
path: path, line: line, column: column,
|
|
1623
|
-
message: message, severity: :info, rule: rule, source_family: :builtin
|
|
1624
|
-
)
|
|
1625
|
-
end
|
|
1626
|
-
|
|
1627
|
-
def location_fields(source_location)
|
|
1628
|
-
return [".rigor.yml", 1, 1] if source_location.nil?
|
|
1629
|
-
|
|
1630
|
-
path = location_path(source_location)
|
|
1631
|
-
line = source_location.respond_to?(:start_line) ? source_location.start_line : 1
|
|
1632
|
-
column = source_location.respond_to?(:start_column) ? source_location.start_column + 1 : 1
|
|
1633
|
-
[path, line, column]
|
|
1634
|
-
rescue StandardError
|
|
1635
|
-
[".rigor.yml", 1, 1]
|
|
1636
|
-
end
|
|
1637
|
-
|
|
1638
|
-
def location_path(source_location)
|
|
1639
|
-
buffer = source_location.respond_to?(:buffer) ? source_location.buffer : nil
|
|
1640
|
-
return ".rigor.yml" if buffer.nil? || !buffer.respond_to?(:name)
|
|
1641
|
-
|
|
1642
|
-
name = buffer.name.to_s
|
|
1643
|
-
name.empty? ? ".rigor.yml" : name
|
|
1644
|
-
end
|
|
1645
|
-
|
|
1646
|
-
# ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
|
|
1647
|
-
# hook once per run, after the loader's `#init` pass and
|
|
1648
|
-
# before per-file iteration. Plugins publish facts here
|
|
1649
|
-
# for cross-plugin consumption via the shared
|
|
1650
|
-
# `services.fact_store`. Failures isolate as
|
|
1651
|
-
# `:plugin_loader runtime-error` diagnostics, mirroring the
|
|
1652
|
-
# `#diagnostics_for_file` raise envelope in
|
|
1653
|
-
# `plugin_runtime_error_diagnostic`.
|
|
1654
|
-
#
|
|
1655
|
-
# Slice 3 visits plugins in registration order. Slice 5
|
|
1656
|
-
# introduces topological ordering by `manifest(consumes:)`
|
|
1657
|
-
# so producers always run before consumers; until then,
|
|
1658
|
-
# `Configuration#plugins` order MUST be producer-first if
|
|
1659
|
-
# cross-plugin dependencies exist.
|
|
1660
|
-
def plugin_prepare_diagnostics
|
|
1661
|
-
return [] if @plugin_registry.empty?
|
|
1662
|
-
|
|
1663
|
-
@plugin_registry.plugins.flat_map { |plugin| invoke_plugin_prepare(plugin) }
|
|
1664
|
-
end
|
|
1665
|
-
|
|
1666
|
-
def invoke_plugin_prepare(plugin)
|
|
1667
|
-
plugin.prepare(plugin.services)
|
|
1668
|
-
[]
|
|
1669
|
-
rescue StandardError => e
|
|
1670
|
-
[plugin_prepare_error_diagnostic(plugin, e)]
|
|
1671
|
-
end
|
|
1672
|
-
|
|
1673
|
-
def plugin_prepare_error_diagnostic(plugin, error)
|
|
1674
|
-
plugin_id = safe_plugin_id(plugin)
|
|
1675
|
-
Diagnostic.new(
|
|
1676
|
-
path: ".rigor.yml",
|
|
1677
|
-
line: 1,
|
|
1678
|
-
column: 1,
|
|
1679
|
-
message: "plugin #{plugin_id.inspect} raised during prepare: " \
|
|
1680
|
-
"#{error.class}: #{error.message}",
|
|
1681
|
-
severity: :error,
|
|
1682
|
-
rule: "runtime-error",
|
|
1683
|
-
source_family: :plugin_loader
|
|
1684
|
-
)
|
|
1685
|
-
end
|
|
1686
|
-
|
|
1687
624
|
# ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
|
|
1688
625
|
# per-file diagnostic emission hook
|
|
1689
626
|
# (`Plugin::Base#diagnostics_for_file`) and re-stamps the
|
|
@@ -1695,17 +632,42 @@ module Rigor
|
|
|
1695
632
|
# I/O Policy" — a raise from one plugin becomes a
|
|
1696
633
|
# `:plugin_loader` `runtime-error` diagnostic without
|
|
1697
634
|
# affecting other plugins or the rest of the run.
|
|
635
|
+
# ADR-52 WD1 — only the plugins that overrode
|
|
636
|
+
# `#diagnostics_for_file` or declared a `node_rule` are visited
|
|
637
|
+
# (`contribution_index.for_file_diagnostics`); a skipped plugin's
|
|
638
|
+
# two hooks could only have returned `[]`.
|
|
1698
639
|
def plugin_emitted_diagnostics(path, root, scope)
|
|
1699
640
|
return [] if @plugin_registry.empty?
|
|
1700
641
|
|
|
1701
|
-
|
|
1702
|
-
|
|
642
|
+
# ADR-52 WD4 — one engine-owned AST walk per file dispatches each
|
|
643
|
+
# node to every matching (plugin, rule); the per-plugin results
|
|
644
|
+
# are bucketed in registry order so emission stays plugin-major
|
|
645
|
+
# (byte-identical with the old per-plugin walk).
|
|
646
|
+
node_results = node_rule_results_by_plugin(path, root, scope)
|
|
647
|
+
|
|
648
|
+
@plugin_registry.contribution_index.for_file_diagnostics.flat_map do |plugin|
|
|
649
|
+
collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
|
|
1703
650
|
end
|
|
1704
651
|
end
|
|
1705
652
|
|
|
1706
|
-
def
|
|
653
|
+
def node_rule_results_by_plugin(path, root, scope)
|
|
654
|
+
walk = @plugin_registry.node_rule_walk
|
|
655
|
+
return {}.compare_by_identity if walk.empty?
|
|
656
|
+
|
|
657
|
+
results = walk.diagnostics_for_file(path: path, scope: scope, root: root)
|
|
658
|
+
results.each_with_object({}.compare_by_identity) do |result, by_plugin|
|
|
659
|
+
by_plugin[result.plugin] = result
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
|
|
1707
664
|
raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
|
|
1708
|
-
|
|
665
|
+
# A node-rule context/rule raise isolates the whole plugin's
|
|
666
|
+
# node-rule contribution, matching the old combined per-plugin
|
|
667
|
+
# rescue (which discarded `diagnostics_for_file` output too).
|
|
668
|
+
raise node_result.error if node_result&.error
|
|
669
|
+
|
|
670
|
+
raw += node_result.diagnostics if node_result
|
|
1709
671
|
raw.map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
|
|
1710
672
|
rescue StandardError => e
|
|
1711
673
|
[plugin_runtime_error_diagnostic(path, plugin, e)]
|
|
@@ -1831,29 +793,37 @@ module Rigor
|
|
|
1831
793
|
# without the project pre-pass (e.g. a single-file probe)
|
|
1832
794
|
# keeps an empty seed.
|
|
1833
795
|
def seed_project_scope(scope)
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
796
|
+
tables = project_scope_seed_tables
|
|
797
|
+
return scope if tables.empty?
|
|
798
|
+
|
|
799
|
+
scope.with_discovery(scope.discovery.with(**tables))
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# The cross-file pre-pass tables {#seed_project_scope} applies, as a
|
|
803
|
+
# plain Hash so the fork-pool path can hand the same seed to its
|
|
804
|
+
# {WorkerSession} (whose per-file scopes would otherwise miss every
|
|
805
|
+
# cross-file def — ADR-15 sequential-equivalence contract).
|
|
806
|
+
def project_scope_seed_tables
|
|
807
|
+
tables = {}
|
|
808
|
+
tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
|
|
809
|
+
tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
|
|
810
|
+
tables[:discovered_def_sources] = @project_discovered_def_sources unless @project_discovered_def_sources.empty?
|
|
1841
811
|
unless @project_discovered_superclasses.empty?
|
|
1842
|
-
|
|
812
|
+
tables[:discovered_superclasses] = @project_discovered_superclasses
|
|
1843
813
|
end
|
|
1844
|
-
|
|
814
|
+
tables[:discovered_includes] = @project_discovered_includes unless @project_discovered_includes.empty?
|
|
1845
815
|
unless @project_discovered_method_visibilities.empty?
|
|
1846
|
-
|
|
816
|
+
tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
|
|
1847
817
|
end
|
|
1848
|
-
|
|
1849
|
-
|
|
818
|
+
tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
|
|
819
|
+
tables[:data_member_layouts] = @project_data_member_layouts unless @project_data_member_layouts.empty?
|
|
1850
820
|
# ADR-46 slice 1 — the class-declaration source map is read only by
|
|
1851
821
|
# the ancestry accessors during dependency recording, so seed it
|
|
1852
822
|
# only when recording is on; a normal run never carries it.
|
|
1853
823
|
if @record_dependencies && !@project_discovered_class_sources.empty?
|
|
1854
|
-
|
|
824
|
+
tables[:discovered_class_sources] = @project_discovered_class_sources
|
|
1855
825
|
end
|
|
1856
|
-
|
|
826
|
+
tables
|
|
1857
827
|
end
|
|
1858
828
|
|
|
1859
829
|
# ADR-46 slice 1 — when dependency recording is enabled, wrap the
|