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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. 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. Kept inline (not a helper) so the engine's own
145
- # flow analysis sees the ivars established in the constructor.
146
- @class_decl_paths_snapshot = {}.freeze
147
- @signature_paths_snapshot = [].freeze
148
- @synthesized_namespaces_snapshot = [].freeze
149
- # `rigor:v1:conforms-to` results, snapshotted from the
150
- # per-run RBS env in `analyze_files_sequentially` (gated on
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
- @class_decl_paths_snapshot = {}.freeze
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
- run_project_pre_passes(expansion: expansion)
433
- ProjectScan.new(
434
- plugin_registry: @plugin_registry,
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 and stores
444
- # the results on instance variables in the order the
445
- # downstream `#run` body expects. Extracted so
446
- # `#prepare_project_scan` and the prebuilt-less `#run` path
447
- # share one implementation.
448
- def run_project_pre_passes(expansion:) # rubocop:disable Metrics/AbcSize
449
- @plugin_registry = load_plugins
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
- @plugin_registry = scan.plugin_registry
522
- @dependency_source_index = scan.dependency_source_index
523
- @synthetic_method_index = scan.synthetic_method_index
524
- @project_patched_methods = scan.project_patched_methods
525
- @cached_plugin_prepare_diagnostics = scan.plugin_prepare_diagnostics
526
- @pre_eval_diagnostics_from_scanner = scan.pre_eval_diagnostics
527
- end
528
- private :run_project_pre_passes, :adopt_prebuilt_project_scan
529
-
530
- # ADR-15 Phase 4b routes per-file analysis to either the
531
- # sequential coordinator-side Environment (legacy path,
532
- # default) or a Ractor worker pool built around
533
- # {WorkerSession} (opt-in via `workers:`). The sequential
534
- # path is bit-for-bit unchanged from v0.1.4 / earlier; the
535
- # pool path is the substrate exercised by phase 4c when
536
- # `RIGOR_RACTOR_WORKERS` / `.rigor.yml` `parallel.workers:`
537
- # is wired.
538
- #
539
- # Sequential mode also snapshots `class_decl_paths` from the
540
- # local environment after the per-file loop completes so
541
- # `RunStats` can attribute the RBS class universe between
542
- # project-sig and bundled sources. The env stays a LOCAL
543
- # variable (not an ivar) so it goes GC-eligible when the
544
- # method returns holding it as long-lived state added
545
- # memory pressure that surfaced as a Bus Error during the
546
- # spec suite under Ruby 4.0 + rbs 4.0.2.
547
- def analyze_files(files, environment: nil)
548
- return [] if files.empty?
549
- return dispatch_pool(files) if pool_mode?
550
-
551
- analyze_files_sequentially(files, environment || resolve_sequential_environment(source_files: files))
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
- # ADR-15 Phase 4b pool mode is enabled when `@workers > 0`.
739
- # Editor mode (`buffer:` non-nil) silently overrides pool
740
- # mode to sequential: per design § "Ractor pool mode", the
741
- # pool's warm-up cost dominates one-file wall time, so the
742
- # pool gains nothing on a per-buffer invocation. The override
743
- # is part of the contract not a degradation diagnostic —
744
- # because `--workers=N` is a project-scale knob and editor
745
- # mode is per-buffer; the conflict resolves toward the more
746
- # specific axis.
747
- def pool_mode?
748
- return false unless @workers.is_a?(Integer) && @workers.positive?
749
-
750
- @buffer.nil?
751
- end
752
-
753
- # ADR-15 Amendment (2026-05-20) worker-pool backend selector.
754
- # `fork` is the active backend: separate processes sidestep both
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
- bundler_bundle_path: @configuration.bundler_bundle_path,
802
- bundler_auto_detect: @configuration.bundler_auto_detect,
803
- bundler_lockfile: @configuration.bundler_lockfile,
804
- rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
805
- rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
806
- synthetic_method_index: @synthetic_method_index,
807
- project_patched_methods: @project_patched_methods,
808
- source_files: source_files
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
- cache_store: @cache_store,
957
- plugin_blueprints: @plugin_registry.blueprints,
958
- explain: @explain,
959
- synthetic_method_index: @synthetic_method_index,
960
- project_patched_methods: @project_patched_methods,
961
- source_files: files
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
- # Child-process body for {#analyze_files_in_fork_pool}. Analyses
993
- # the slice with the copy-on-write-inherited session and writes
994
- # the Marshal'd payload to `out_path`. `exit!` skips `at_exit` /
995
- # stdio flush — the payload is already durable on disk by then.
996
- def run_fork_worker(session, slice, out_path)
997
- results = slice.to_h { |path| [path, session.analyze(path)] }
998
- payload = { results: results, reporters: session.drain_reporters }
999
- File.binwrite(out_path, Marshal.dump(payload))
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 = @class_decl_paths_snapshot
606
+ snapshot = @snapshots.class_decl_paths
1064
607
  project_sig, bundled = RunStats.partition_classes(
1065
608
  class_decl_paths: snapshot,
1066
- signature_paths: @signature_paths_snapshot
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
- @plugin_registry.plugins.flat_map do |plugin|
1702
- collect_plugin_diagnostics(plugin, path, root, scope)
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 collect_plugin_diagnostics(plugin, path, root, scope)
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
- raw += plugin.node_rule_diagnostics(path: path, scope: scope, root: root)
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
- scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
1835
- unless @project_discovered_def_nodes.empty?
1836
- scope = scope.with_discovered_def_nodes(@project_discovered_def_nodes)
1837
- end
1838
- unless @project_discovered_def_sources.empty?
1839
- scope = scope.with_discovered_def_sources(@project_discovered_def_sources)
1840
- end
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
- scope = scope.with_discovered_superclasses(@project_discovered_superclasses)
812
+ tables[:discovered_superclasses] = @project_discovered_superclasses
1843
813
  end
1844
- scope = scope.with_discovered_includes(@project_discovered_includes) unless @project_discovered_includes.empty?
814
+ tables[:discovered_includes] = @project_discovered_includes unless @project_discovered_includes.empty?
1845
815
  unless @project_discovered_method_visibilities.empty?
1846
- scope = scope.with_discovered_method_visibilities(@project_discovered_method_visibilities)
816
+ tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
1847
817
  end
1848
- scope = scope.with_discovered_methods(@project_discovered_methods) unless @project_discovered_methods.empty?
1849
- scope = scope.with_data_member_layouts(@project_data_member_layouts) unless @project_data_member_layouts.empty?
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
- scope = scope.with_discovered_class_sources(@project_discovered_class_sources)
824
+ tables[:discovered_class_sources] = @project_discovered_class_sources
1855
825
  end
1856
- scope
826
+ tables
1857
827
  end
1858
828
 
1859
829
  # ADR-46 slice 1 — when dependency recording is enabled, wrap the