rigortype 0.3.7 → 0.3.8

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.
@@ -115,11 +115,62 @@ module Rigor
115
115
  # sources. The env stays a LOCAL variable (not an ivar) so it goes GC-eligible when the method
116
116
  # returns — holding it as long-lived state added memory pressure that surfaced as a Bus Error
117
117
  # during the spec suite under Ruby 4.0 + rbs 4.0.2.
118
- def analyze_files(files, environment: nil)
119
- return [] if files.empty?
120
- return dispatch_pool(files) if pool_mode?
121
-
122
- analyze_files_sequentially(files, environment || resolve_sequential_environment(source_files: files))
118
+ # @param project_files [Array<String>, nil] issue #784 — the WHOLE project's analyzed file set
119
+ # (`expansion.fetch(:files)`), independent of any `analyze_only` narrowing of `files`. Read only
120
+ # when `files` is empty, to decide whether anyone could have demanded the HKT registry at all.
121
+ def analyze_files(files, environment: nil, project_files: nil)
122
+ if files.empty?
123
+ # Issue #784 — an EMPTY analyze set still owes the run its HKT-scan row: the per-file cache never
124
+ # holds it (`IncrementalSession` caches only `Runner#per_file_diagnostics`, so every run-level row
125
+ # is regenerated every run), so returning here without recording flips a red project green — and
126
+ # the shipping `--incremental` path reaches this branch with NO environment in hand on every
127
+ # warm recheck that changed nothing (`CheckCommand#run_incremental_check` builds its session
128
+ # without one). So: an environment already in hand is consulted; otherwise one is resolved over
129
+ # the project's OWN file list — not `[]`, which would drop every plugin-synthesized virtual RBS
130
+ # (`Environment.collect_virtual_rbs` short-circuits on an empty list) and scan a different type
131
+ # universe from the one a full run analyses — and only when the project HAS files: with none,
132
+ # nobody could have demanded a registry, and an empty project keeps paying no env build. Keyed
133
+ # on the project's files rather than on `analyze_only`, because a recheck over an EMPTY project
134
+ # narrows to `Set[]`, which is non-nil.
135
+ env = environment || @environment_override
136
+ if project_files && !project_files.empty?
137
+ env ||= resolve_sequential_environment(source_files: project_files)
138
+ end
139
+ # #788 rounds 6 and 9 — everything the run owes from its environment that no per-file analysis
140
+ # produces is taken here, the way `analyze_files_sequentially` takes it: the project-signature
141
+ # state (`synthesized-namespace`, `quarantined-signature`, `environment-build-failed`, the
142
+ # conformance results), the effect-annotation carrier the residual pass reads, and the HKT-scan
143
+ # outcome. Now that run-level rows are never served from the per-file cache, this branch is the
144
+ # only producer on a warm recheck that changed nothing — leaving any of them out turned a red
145
+ # project green on its second `--incremental` run (the inline-only `effect.annotations-unchecked`
146
+ # went 1 → 0; a quarantined `signature_paths:` file went 1 → 0; a `conforms-to` class whose
147
+ # definition build fails went 1 → 0). Same ORDER as the sequential path, because the order is
148
+ # the contract: the conformance scan inside the signature-state snapshot demands the definition
149
+ # of every `rigor:v1:conforms-to` class — a user-authored, invocation-independent demand that
150
+ # #696 counts — so the definition-build failures are read AFTER it and BEFORE the HKT demand,
151
+ # exactly where `analyze_files_sequentially` reads them relative to its own. What this branch
152
+ # cannot regenerate is the part of that set the per-file ANALYSIS demanded (#796). This also
153
+ # retires the #441 "`.rbs` lane only when the run analyses nothing" boundary: its cost premise
154
+ # (no environment on this path) stopped holding the moment the branch above resolved one.
155
+ snapshot_project_signature_state(env)
156
+ snapshot_effect_annotation_carrier(env&.rbs_loader)
157
+ record_definition_build_failures(env&.rbs_loader&.definition_build_failures)
158
+ record_hkt_scan_failure(hkt_scan_outcome(env))
159
+ return []
160
+ end
161
+ # Issue #784 / #793 — `files` is what this run ANALYSES; `source_files` is what its environment
162
+ # is BUILT over, and the two are the same only on a full run. A narrowed run (`analyze_only`: an
163
+ # incremental closure, a `--verify-incremental` partition) used to build its environment over the
164
+ # subset, so plugin-synthesized virtual RBS from every excluded file was missing and the run
165
+ # scanned a different type universe than the full run it is compared against — a scan failure
166
+ # whose trigger lived in an excluded file's synthesized RBS fired on the full run and vanished on
167
+ # the subset. Every environment and worker this run builds now takes the whole project.
168
+ source_files = project_files || files
169
+ return dispatch_pool(files, source_files: source_files) if pool_mode?
170
+
171
+ analyze_files_sequentially(
172
+ files, environment || resolve_sequential_environment(source_files: source_files)
173
+ )
123
174
  end
124
175
 
125
176
  def analyze_files_sequentially(files, environment)
@@ -130,6 +181,13 @@ module Rigor
130
181
  # first demand), so a snapshot taken beside the ones above — which run BEFORE `files.flat_map` —
131
182
  # would read an empty list on every run, including the ones this diagnostic exists for.
132
183
  record_definition_build_failures(environment&.rbs_loader&.definition_build_failures)
184
+ # Issue #784 — same timing contract, same reason: the HKT scan is first demanded from inside a
185
+ # file's analysis (the dispatcher's Singleton-receiver tier), so a snapshot taken any earlier
186
+ # would read nil on every run. And demanded HERE once more by the run itself, because a subset
187
+ # run (`--verify-incremental`'s partition, an incremental recheck's closure) may contain no file
188
+ # that demands it — the row must not depend on which files were analysed. See {#hkt_scan_outcome}
189
+ # for why a Rigor-owned demand is sound here where #696 forbids it.
190
+ record_hkt_scan_failure(hkt_scan_outcome(environment))
133
191
  if @collect_stats
134
192
  loader = environment.rbs_loader
135
193
  @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
@@ -156,7 +214,8 @@ module Rigor
156
214
  return
157
215
  end
158
216
 
159
- loader = environment.rbs_loader
217
+ # nil-safe: the empty-closure path reaches here with no environment when the project has no files.
218
+ loader = environment&.rbs_loader
160
219
  @snapshots.synthesized_namespaces = loader&.synthesized_namespaces || []
161
220
  @snapshots.quarantined_signatures = loader&.quarantined_signatures || []
162
221
  @snapshots.env_build_failure = loader&.env_build_failure
@@ -220,20 +279,24 @@ module Rigor
220
279
  # An effects run (ADR-103 WD13) is pinned for exactly the same reason — the Ractor messages carry
221
280
  # no side-table channel — and degrades the same way. The degrade is sound rather than merely safe:
222
281
  # the sequential fallback still collects, so the effect graph is complete either way.
223
- def dispatch_pool(files)
282
+ # @param source_files [Array<String>] the file list every worker's / the fallback's environment is
283
+ # built over — the whole project (issue #793), defaulting to `files` for direct callers.
284
+ def dispatch_pool(files, source_files: files)
224
285
  if @record_dependencies || @record_effects
225
- return analyze_files_in_fork_pool(files) if Process.respond_to?(:fork)
286
+ return analyze_files_in_fork_pool(files, source_files: source_files) if Process.respond_to?(:fork)
226
287
 
227
288
  return analyze_files_sequentially_fallback(
228
- files, reason: "incremental parallelism requires fork; recording sequentially"
289
+ files, reason: "incremental parallelism requires fork; recording sequentially",
290
+ source_files: source_files
229
291
  )
230
292
  end
231
293
  case pool_backend
232
- when :ractor then analyze_files_in_pool(files)
233
- when :fork then analyze_files_in_fork_pool(files)
294
+ when :ractor then analyze_files_in_pool(files, source_files: source_files)
295
+ when :fork then analyze_files_in_fork_pool(files, source_files: source_files)
234
296
  else
235
297
  analyze_files_sequentially_fallback(
236
- files, reason: "fork-based parallelism is unavailable on this platform"
298
+ files, reason: "fork-based parallelism is unavailable on this platform",
299
+ source_files: source_files
237
300
  )
238
301
  end
239
302
  end
@@ -299,7 +362,7 @@ module Rigor
299
362
  # worker touches comes back as an internal analyzer error. What the code below fixes is the
300
363
  # failure MODE, not the backend: the run used to hang forever instead of saying anything. Reviving
301
364
  # the backend needs an upstream change, which is why `pool_backend` keeps `fork` as the default.
302
- def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
365
+ def analyze_files_in_pool(files, source_files: files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
303
366
  # Pre-warm class-level lazy memos on the MAIN Ractor. `Environment::ClassRegistry.default` is the
304
367
  # default kwarg threaded through `Environment.new` inside each worker session; lazy-initialising
305
368
  # it from a non-main Ractor would trip `Ractor::IsolationError`. Touching it here forces the
@@ -314,10 +377,28 @@ module Rigor
314
377
  # constants.
315
378
  if @cache_store.nil?
316
379
  return analyze_files_sequentially_fallback(
317
- files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
380
+ files, reason: "pool mode requires a cache_store (--no-cache disables pool)",
381
+ source_files: source_files
318
382
  )
319
383
  end
320
- prewarm_rbs_cache_for_pool
384
+ # Issue #798 — the SAME gap the fork pool had: this coordinator never analyses a file itself, so
385
+ # without an explicit read the project-signature state (`synthesized-namespace`,
386
+ # `quarantined-signature`, `environment-build-failed`, the conformance results) has no producer.
387
+ # Unlike the fork pool's copy-on-write children, a Ractor worker builds its OWN Environment
388
+ # inside its own isolated Ractor, so nothing here inherits what the coordinator's environment
389
+ # finds. `#prewarm_rbs_cache_for_pool` already builds and fully loads exactly this
390
+ # coordinator-side environment (to warm the cache before any worker spawns); it now hands it
391
+ # back so its RBS state can be read the same way the fork pool reads its pre-fork session
392
+ # environment. The conformance scan inside the snapshot demands the definition of every
393
+ # `rigor:v1:conforms-to` class — a demand no Ractor worker shares memory with — so a resulting
394
+ # definition-build failure is recorded explicitly right after, exactly as the empty-closure
395
+ # branch reads its own environment's demand (#788). No matching explicit call for the HKT-scan
396
+ # outcome: every worker already demands it from its OWN environment in `#drain_reporters`, and
397
+ # the scan has one outcome whoever demands it, so a worker's report already says what the
398
+ # coordinator's own demand would.
399
+ warm_env = prewarm_rbs_cache_for_pool
400
+ snapshot_project_signature_state(warm_env)
401
+ record_definition_build_failures(warm_env&.rbs_loader&.definition_build_failures)
321
402
 
322
403
  configuration = @configuration
323
404
  cache_root = @cache_store&.root
@@ -325,8 +406,9 @@ module Rigor
325
406
  explain = @explain
326
407
  # ADR-32 WD4 — the full project file list travels into every Ractor worker so each worker's
327
408
  # WorkerSession can invoke loaded plugins' source_rbs_synthesizers at env-build time. The list is
328
- # a frozen Array<String>; cheaply shareable.
329
- shareable_source_files = files.map { |path| path.to_s.dup.freeze }.freeze
409
+ # a frozen Array<String>; cheaply shareable. Issue #793 — it IS the full project now
410
+ # (`source_files`), not the analyzed subset this comment always described.
411
+ shareable_source_files = source_files.map { |path| path.to_s.dup.freeze }.freeze
330
412
 
331
413
  pool = Array.new(@workers) do
332
414
  Ractor.new(configuration, cache_root, blueprints, explain, shareable_source_files) do |configuration, cache_root, blueprints, explain, shareable_source_files| # rubocop:disable Layout/LineLength
@@ -397,15 +479,28 @@ module Rigor
397
479
  # The files no worker reported — every file of a worker that died, and any file in flight when
398
480
  # it did. Re-analysed in process, exactly as the fork backend re-analyses a dead child's slice.
399
481
  degraded = files.reject { |path| results_by_path.key?(path) }
400
- unless degraded.empty?
401
- environment = build_runner_environment(source_files: files)
402
- degraded.each { |path| results_by_path[path] = @analyze_file.call(path, environment) }
403
- end
482
+ reanalyze_degraded_in_process(degraded, results_by_path, source_files: source_files)
404
483
 
405
484
  diagnostics = Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
406
485
  degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "ractor"))
407
486
  end
408
487
 
488
+ # The Ractor backend's degrade: the files of a worker that died are re-analysed on a LOCAL
489
+ # environment built over the whole project. That worker never sent `:done`, so nothing drains its
490
+ # reporters — and the local environment has no session to drain either — so the run-level state
491
+ # this analysis produced is taken here, exactly as the sequential fallback takes it: the
492
+ # definition-build failures the re-analysis demanded (#696) and the HKT-scan outcome, demanded
493
+ # once more by the run itself (#784 — a rescued scan failure otherwise vanished with the worker).
494
+ # The fork backend needs none of this: it re-analyses on the parent {WorkerSession} and drains it.
495
+ def reanalyze_degraded_in_process(degraded, results_by_path, source_files:)
496
+ return if degraded.empty?
497
+
498
+ environment = build_runner_environment(source_files: source_files)
499
+ degraded.each { |path| results_by_path[path] = @analyze_file.call(path, environment) }
500
+ record_definition_build_failures(environment.rbs_loader&.definition_build_failures)
501
+ record_hkt_scan_failure(hkt_scan_outcome(environment))
502
+ end
503
+
409
504
  # ADR-15 Amendment (2026-05-20) — fork-based worker pool, the active backend for `workers > 0`.
410
505
  # Builds ONE {WorkerSession} on the parent, then `fork`s N children that copy-on-write inherit it.
411
506
  # Each child analyses a contiguous slice of `files` and writes a Marshal'd `{results:, reporters:}`
@@ -418,7 +513,11 @@ module Rigor
418
513
  #
419
514
  # A child that exits non-zero (crash / unmarshalable payload) is degraded: the parent re-analyses
420
515
  # that slice in-process and prepends a `pool-degraded` warning.
421
- def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
516
+ #
517
+ # Also snapshots the project-signature state and the effect-annotation carrier off the pre-fork
518
+ # `session.environment` before any child spawns (#798) — the only environment this backend's
519
+ # coordinator ever holds, and so the only place those diagnostic rows can be read from.
520
+ def analyze_files_in_fork_pool(files, source_files: files) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
422
521
  Environment::ClassRegistry.default
423
522
 
424
523
  session = WorkerSession.new(
@@ -429,12 +528,27 @@ module Rigor
429
528
  synthetic_method_index: synthetic_method_index,
430
529
  project_patched_methods: project_patched_methods,
431
530
  project_scope_seed: project_scope_seed,
432
- source_files: files,
531
+ source_files: source_files,
433
532
  record_dependencies: @record_dependencies
434
533
  )
435
534
  # Force the full RBS load on the parent so children copy-on-write inherit a warm Environment
436
535
  # rather than each rebuilding it after the fork.
437
536
  session.environment.rbs_loader&.prewarm
537
+ # Issue #798 — same set, same ORDER as the sequential path (docs/type-specification/
538
+ # diagnostic-policy.md § `rbs.coverage.*`): the project-signature state
539
+ # (`synthesized-namespace`, `quarantined-signature`, `environment-build-failed`, the
540
+ # conformance results) has no per-file producer, so this coordinator-side environment is the
541
+ # only place a pooled run can take it from — exactly as the empty-closure branch above is the
542
+ # only place an empty run can (#788). Taken BEFORE the fork below: the conformance scan this
543
+ # runs demands the definition of every `rigor:v1:conforms-to` class, and doing that HERE means
544
+ # a resulting definition-build failure is already sitting in the parent's loader at the moment
545
+ # each child copy-on-write-inherits it — every child's own `#drain_reporters` then reports it
546
+ # right alongside whatever its own slice demanded, with no separate plumbing needed. Left
547
+ # unconditional, unlike `#snapshot_fork_pool_stats` below: these are diagnostic rows, not
548
+ # `RunStats` telemetry, and the stats gate must not decide which diagnostics a run reports (a
549
+ # `--workers N --no-stats` run used to say strictly LESS than the sequential path over the
550
+ # same project).
551
+ snapshot_project_signature_state(session.environment)
438
552
  snapshot_effect_annotation_carrier(session.environment.rbs_loader)
439
553
  snapshot_fork_pool_stats(session) if @collect_stats
440
554
 
@@ -488,18 +602,16 @@ module Rigor
488
602
  exit!(1)
489
603
  end
490
604
 
491
- # Snapshots `class_decl_paths` from the parent session's loader so end-of-run {RunStats} can
492
- # attribute the RBS class universe.
605
+ # Issue #798 — `RunStats` telemetry ONLY, now that `#snapshot_project_signature_state` (called
606
+ # unconditionally above) owns every diagnostic-bearing slot this method used to ALSO write
607
+ # (`quarantined_signatures`, `env_build_failure`): those must not be gated on `@collect_stats`, and
608
+ # this method's own name says what is left — `class_decl_paths` / `signature_paths`, read off the
609
+ # parent session's loader so end-of-run {RunStats} can attribute the RBS class universe, which a
610
+ # `--no-stats` run legitimately skips.
493
611
  def snapshot_fork_pool_stats(session)
494
612
  loader = session.environment.rbs_loader
495
613
  @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
496
614
  @snapshots.signature_paths = loader&.signature_paths || [].freeze
497
- # The workers each quarantine the same broken file, but they report no diagnostics for it — the row is
498
- # a whole-run one. Read it off the parent session's loader so a pooled run says exactly what a
499
- # sequential one says. The same reasoning holds for a total env-build failure.
500
- @snapshots.quarantined_signatures =
501
- project_signature_paths? ? (loader&.quarantined_signatures || []) : []
502
- @snapshots.env_build_failure = project_signature_paths? ? loader&.env_build_failure : nil
503
615
  end
504
616
 
505
617
  # Waits for every forked child, merges each successful payload into `results_by_path`, and returns
@@ -545,9 +657,10 @@ module Rigor
545
657
 
546
658
  # ADR-15 Phase 4b.x — drives every cached RBS producer on the main Ractor so each worker can serve
547
659
  # all reflection queries from disk (Marshal-load only). Builds a single coordinator-side
548
- # {Environment} for this purpose; the env object is discarded immediately after the cache is warm
549
- # workers build their own `Environment.for_project` inside the Ractor body, which then routes
550
- # through `cached_env` instead of `RBS::EnvironmentLoader.new`.
660
+ # {Environment} for this purpose and returns it fully loaded issue #798: the caller also reads
661
+ # the project-signature state off it, since it is the only environment this backend's coordinator
662
+ # ever holds. Workers still build their OWN `Environment.for_project` inside the Ractor body, which
663
+ # then routes through `cached_env` instead of `RBS::EnvironmentLoader.new`.
551
664
  def prewarm_rbs_cache_for_pool
552
665
  warm_env = Environment.for_project(
553
666
  libraries: @configuration.libraries,
@@ -560,14 +673,18 @@ module Rigor
560
673
  rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
561
674
  )
562
675
  warm_env.rbs_loader&.prewarm
676
+ warm_env
563
677
  end
564
678
 
565
679
  # ADR-15 Phase 4b.x — pool-mode safety net. When pool mode is configured but a precondition fails
566
680
  # (currently: `--no-cache` would force workers through `EnvironmentLoader.new`), degrade to
567
681
  # sequential analysis with a `:warning` `pool-degraded` diagnostic at run start. The actual
568
682
  # per-file analysis runs on the coordinator, identical to the default sequential path.
569
- def analyze_files_sequentially_fallback(files, reason:)
570
- environment = build_runner_environment
683
+ # @param source_files [Array<String>] issue #793 — the whole project, so this path's environment
684
+ # carries the same plugin-synthesized RBS the pool workers' would. It used to build over `[]`,
685
+ # i.e. with no synthesized RBS at all, even on a full run.
686
+ def analyze_files_sequentially_fallback(files, reason:, source_files: files)
687
+ environment = build_runner_environment(source_files: source_files)
571
688
  snapshot_effect_annotation_carrier(environment.rbs_loader)
572
689
  diagnostics = files.flat_map { |path| @analyze_file.call(path, environment) }
573
690
  loader = environment.rbs_loader
@@ -576,6 +693,10 @@ module Rigor
576
693
  # `fork` is unavailable (Windows) and on `--incremental` / effects runs without it: a run that
577
694
  # degraded to sequential must not also report less than a sequential run would.
578
695
  record_definition_build_failures(loader&.definition_build_failures)
696
+ # Issue #784 — same reasoning: this path's Environment IS the one that reached the scan, so it
697
+ # must snapshot the slot too, or a run that degraded to sequential would report less than a
698
+ # sequential run would. Demanded once by the run as well, for the reason at the sequential site.
699
+ record_hkt_scan_failure(hkt_scan_outcome(environment))
579
700
  @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
580
701
  @snapshots.signature_paths = loader&.signature_paths || [].freeze
581
702
  @snapshots.quarantined_signatures =
@@ -591,17 +712,7 @@ module Rigor
591
712
  end
592
713
 
593
714
  def merge_worker_reporters(drained)
594
- rbs = drained.fetch(:rbs_extended)
595
- rbs.fetch(:unresolved_payloads).each do |entry|
596
- @rbs_extended_reporter.record_unresolved(
597
- payload: entry.payload, source_location: entry.source_location
598
- )
599
- end
600
- rbs.fetch(:lossy_projections).each do |entry|
601
- @rbs_extended_reporter.record_lossy_projection(
602
- head: entry.head, source_location: entry.source_location
603
- )
604
- end
715
+ merge_rbs_extended_reporter(drained.fetch(:rbs_extended))
605
716
  drained.fetch(:boundary_cross).each do |entry|
606
717
  @boundary_cross_reporter.record(
607
718
  class_name: entry.class_name,
@@ -619,10 +730,42 @@ module Rigor
619
730
  end
620
731
  # Issue #696. Fetched with a default so an older drain stays compatible, exactly as the line above.
621
732
  record_definition_build_failures(drained[:definition_build_failures])
733
+ # Issue #784. `Hash#[]` is already a nil default, exactly as `env_build_failure` is snapshotted
734
+ # elsewhere — an older drain shape simply has no key and records nothing.
735
+ record_hkt_scan_failure(drained[:hkt_scan_failure])
622
736
  end
623
737
 
624
738
  private
625
739
 
740
+ # Replays one worker's three {RbsExtended::Reporter} streams into the run's own reporter.
741
+ #
742
+ # `hkt_directive_errors` (issue #785) is read with `Hash#[]`'s nil default rather than `fetch`,
743
+ # exactly as `source_rbs_synthesis` is, so an older drain shape simply records nothing.
744
+ #
745
+ # All three streams replay by their `(path, line, column)` primitives (#785 for the hkt stream, #805
746
+ # for its two elders). Every worker reads the same `.rbs`, so each hands over the same entries, and
747
+ # only a primitive-keyed dedup collapses them back to the single row a `--workers=0` run prints — an
748
+ # `RBS::Location` compares equal only against a location over the same `RBS::Buffer` object, so a
749
+ # location-keyed one would print N copies at `--workers=N`. The entries could not reach here carrying
750
+ # a location anyway: the drain Marshals its payload and an `RBS::Location` has no `_dump`.
751
+ def merge_rbs_extended_reporter(rbs)
752
+ rbs.fetch(:unresolved_payloads).each do |entry|
753
+ @rbs_extended_reporter.record_unresolved(
754
+ payload: entry.payload, path: entry.path, line: entry.line, column: entry.column
755
+ )
756
+ end
757
+ rbs.fetch(:lossy_projections).each do |entry|
758
+ @rbs_extended_reporter.record_lossy_projection(
759
+ head: entry.head, path: entry.path, line: entry.line, column: entry.column
760
+ )
761
+ end
762
+ Array(rbs[:hkt_directive_errors]).each do |entry|
763
+ @rbs_extended_reporter.record_hkt_error(
764
+ message: entry.message, path: entry.path, line: entry.line, column: entry.column
765
+ )
766
+ end
767
+ end
768
+
626
769
  # Issue #696 — accumulate the per-class `RBS::DefinitionBuilder` failures this run observed, deduped
627
770
  # by class name and kept in first-seen order.
628
771
  #
@@ -646,6 +789,48 @@ module Rigor
646
789
  (@snapshots.definition_build_failures + failures).uniq(&:first).freeze
647
790
  end
648
791
 
792
+ # Issue #784 — first-wins, unlike {#record_definition_build_failures}'s accumulate-and-dedup. That
793
+ # method accumulates because each pool WORKER owns its own loader and its own per-class memo, so a
794
+ # collapsed class can genuinely be observed by only some workers and the run's set is the union.
795
+ # The HKT scan has no such per-worker variation: `Environment#hkt_registry` builds from the SAME
796
+ # `signature_paths:` overlay every worker was handed, so every worker that demands it either all
797
+ # raise identically or all succeed — there is only ever one tuple to record, and `||=` is correct
798
+ # (and cheap) rather than an accumulate-and-dedup this slot never needs.
799
+ def record_hkt_scan_failure(tuple)
800
+ @snapshots.hkt_scan_failure ||= tuple
801
+ end
802
+
803
+ # Issue #784 — demand the registry once on behalf of the run, then read the slot. The seam in
804
+ # {Environment#hkt_registry} is demand-driven, and nothing guarantees any analysed file demands it:
805
+ # a `--verify-incremental` partition or an incremental closure can miss every `Klass.method` call,
806
+ # and then the slot is nil after the loop and the run says nothing — while `--incremental` has also
807
+ # dropped the row from its per-file cache. Demanding here makes the outcome a property of the RUN,
808
+ # not of which files happened to be in it.
809
+ #
810
+ # This is sound where #696's "a demand that is Rigor's own MUST NOT contribute" is not, and the
811
+ # difference is the shape of what is recorded. #696 reports a per-class LIST whose membership is
812
+ # "the classes the analysis demanded"; a Rigor-internal demand adds classes the user never asked
813
+ # about and makes that list vary with configuration. The HKT scan is ONE build with ONE outcome —
814
+ # the same tuple whoever demands it — so an extra demand cannot change what is reported, only
815
+ # guarantee it is observed.
816
+ #
817
+ # What it costs, stated plainly because {#record_definition_build_failures}'s comment promises
818
+ # "nothing is forced here": the demand DOES force the RBS env build when the environment has not
819
+ # built it yet (the scan reads the loader). On every path that reaches this method that build is
820
+ # either already done (the loop demanded it) or the same load one analysed file would have paid:
821
+ # a Marshal load when a cache store exists, the parse a full run pays under `--no-cache`. Never a
822
+ # build on a project with no files (see {#analyze_files}). Memoised, so on a reused Environment
823
+ # this is a hash read.
824
+ #
825
+ # @param environment [Rigor::Environment, nil]
826
+ # @return [Array, nil] the recorded tuple, or nil (no environment, or the scan built)
827
+ def hkt_scan_outcome(environment)
828
+ return nil if environment.nil?
829
+
830
+ environment.hkt_registry
831
+ environment.hkt_scan_failure
832
+ end
833
+
649
834
  # True when the project declares its own `signature_paths:` (the only place the
650
835
  # qualified-name-without-namespace mistake lives).
651
836
  def project_signature_paths?
@@ -17,7 +17,8 @@ module Rigor
17
17
  class RunSnapshots
18
18
  attr_accessor :class_decl_paths, :signature_paths,
19
19
  :synthesized_namespaces, :quarantined_signatures, :conformance_results,
20
- :env_build_failure, :definition_build_failures, :effect_annotation_carrier
20
+ :env_build_failure, :definition_build_failures, :hkt_scan_failure,
21
+ :effect_annotation_carrier
21
22
 
22
23
  # Constructor defaults match the {Runner} constructor: the pre-seed values `build_run_stats` /
23
24
  # `pre_file_diagnostics` read before the first analysis path runs are frozen empties. The
@@ -25,7 +26,9 @@ module Rigor
25
26
  # `[error_class, first_line, buffer_names]` tuple or nothing. Its per-class sibling
26
27
  # `definition_build_failures` (#696) holds a LIST, because a collapsed universe fails many classes,
27
28
  # and is accumulated ACROSS pool workers rather than assigned once — see
28
- # {PoolCoordinator#merge_worker_reporters}.
29
+ # {PoolCoordinator#merge_worker_reporters}. `hkt_scan_failure` (#784) is nil-or-tuple like
30
+ # `env_build_failure`, not a list like `definition_build_failures`: the scan is ONE build over the
31
+ # whole `signature_paths:` overlay, not a per-class one, so it has exactly one outcome per run.
29
32
  def initialize
30
33
  @class_decl_paths = {}.freeze
31
34
  @signature_paths = [].freeze
@@ -34,6 +37,7 @@ module Rigor
34
37
  @conformance_results = [].freeze
35
38
  @env_build_failure = nil
36
39
  @definition_build_failures = [].freeze
40
+ @hkt_scan_failure = nil
37
41
  @effect_annotation_carrier = [].freeze
38
42
  end
39
43
 
@@ -47,6 +51,7 @@ module Rigor
47
51
  @conformance_results = []
48
52
  @env_build_failure = nil
49
53
  @definition_build_failures = []
54
+ @hkt_scan_failure = nil
50
55
  @effect_annotation_carrier = [].freeze
51
56
  end
52
57
  end
@@ -29,6 +29,7 @@ require_relative "../inference/scope_indexer"
29
29
  require_relative "../inference/synthetic_method_scanner"
30
30
  require_relative "../inference/project_patched_scanner"
31
31
  require_relative "../inference/method_dispatcher/file_folding"
32
+ require_relative "crash_signature"
32
33
  require_relative "buffer_binding"
33
34
  require_relative "check_rules"
34
35
  require_relative "dependency_recorder"
@@ -64,7 +65,14 @@ module Rigor
64
65
 
65
66
  attr_reader :cache_store, :plugin_registry, :dependency_source_index,
66
67
  :rbs_extended_reporter, :boundary_cross_reporter,
67
- :analyzed_files, :unresolved_self_calls, :seed_bundles
68
+ :analyzed_files, :unresolved_self_calls, :seed_bundles,
69
+ # #788 rounds 6–7 — the rows per-file analysis produced this run, severity-resolved exactly
70
+ # as the run's stream is and sliced to this run's targets (a pool backend folds `.rigor.yml`
71
+ # prepare / degraded rows into `analyze_files`'s return). `IncrementalSession` caches ONLY
72
+ # this and serves reused files from it without re-stamping: a run-level row is regenerated
73
+ # every run wherever it is positioned, and slicing the run's full stream by path cached the
74
+ # file-positioned ones (`effect.annotations-unchecked`, `source-rbs-*`).
75
+ :per_file_diagnostics
68
76
 
69
77
  # ADR-46 — the per-file cross-file read records this run captured (empty unless
70
78
  # `record_dependencies: true`). Sequential analysis records into `@file_dependencies` via
@@ -302,6 +310,7 @@ module Rigor
302
310
  # See `self_undefined_rule_active?`.
303
311
  @self_undefined_rule_active = nil
304
312
  @analyzed_files = [].freeze
313
+ @per_file_diagnostics = [].freeze
305
314
  # In-memory source map for `#run_source` — `{ logical_path => source String }`. When set,
306
315
  # `parse_source` reads bytes from here instead of disk and `expand_paths` accepts the (possibly
307
316
  # non-existent) logical path. nil on a normal disk-backed run.
@@ -426,6 +435,9 @@ module Rigor
426
435
  # Per-run reset of the environment the cacheable path resolves, reused by the envelope pass so a
427
436
  # run never builds two.
428
437
  @run_environment = nil
438
+ # #788 — the per-file reader is assigned only on the analysis (miss) path; reset it here so a run the
439
+ # ADR-45 result cache serves does not answer with the previous run's rows.
440
+ @per_file_diagnostics = [].freeze
429
441
  # ADR-84 WD2 — roll the return-memo bucket: a fresh frozen token per run makes every per-file scope
430
442
  # of THIS run share one memo bucket while entries from any earlier run in this process (stale after
431
443
  # an edit) become unreachable.
@@ -674,7 +686,14 @@ module Rigor
674
686
  @run_generation = Object.new.freeze
675
687
  run_project_pre_passes(expansion: expansion)
676
688
  ensure_project_discovery(expansion)
677
- environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
689
+ # Issue #795 — `source_files:` is the WHOLE project's file list, never `target_files(expansion)`.
690
+ # `IncrementalSession#build_runner` threads its own `@buffer` into this Runner, so in editor-buffer
691
+ # mode `target_files` narrows to the buffer's single logical path; an environment built over just
692
+ # that path drops every OTHER file's plugin-synthesized virtual RBS, which can flip a re-evaluated
693
+ # return descriptor and wrongly declare a caller's return type unstable (or stable) relative to the
694
+ # full-project answer. This probe never analyzes a file, so it carries none of #788's per-file-cache
695
+ # duplication concern either.
696
+ environment = @pool_coordinator.resolve_sequential_environment(source_files: expansion.fetch(:files))
678
697
  specs.to_h do |spec|
679
698
  [[spec[:class_name], spec[:method_name], spec[:singleton]], evaluate_spec_returns(spec, environment)]
680
699
  end
@@ -927,9 +946,19 @@ module Rigor
927
946
  ).diagnostics
928
947
  end
929
948
 
949
+ # Issue #795 — `source_files:` is the WHOLE project's file list (`expansion.fetch(:files)`), never
950
+ # `target_files(expansion)`. A subset or editor-buffer run's `target_files` narrows to the
951
+ # `analyze_only` closure or the buffer's single logical path; building the envelope walk's
952
+ # environment over that subset drops every plugin-synthesized virtual RBS from an excluded file, so
953
+ # an envelope declared only in that file's inline annotation is invisible to the walk — the same #793
954
+ # gap #788 closed for per-file analysis, left open here because #788's binding sentences scoped
955
+ # themselves to "the environments the per-file analysis builds". This path is exempt from #788's
956
+ # per-file-cache duplication concern: `effect.unknown-label` (and its envelope-pass siblings) are
957
+ # produced by this pass, never by `Runner#per_file_diagnostics`, so `IncrementalSession` never caches
958
+ # them and widening this source list cannot duplicate a row on recheck.
930
959
  def envelope_rbs_loader(expansion)
931
960
  environment = @run_environment ||
932
- @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
961
+ @pool_coordinator.resolve_sequential_environment(source_files: expansion.fetch(:files))
933
962
  environment&.rbs_loader
934
963
  rescue StandardError
935
964
  nil
@@ -985,7 +1014,17 @@ module Rigor
985
1014
  # per-file cache, so it needs the full analyzed set to subtract the affected closure from.
986
1015
  targets = target_files(expansion)
987
1016
  @analyzed_files = targets
988
- diagnostics += @pool_coordinator.analyze_files(targets, environment: environment)
1017
+ # Issue #784 — the whole project's file list rides along so an EMPTY `targets` (a narrowed run whose
1018
+ # closure is empty) can still resolve the environment a full run would — same files, same
1019
+ # synthesized RBS — to demand the HKT registry once, while a project with no files resolves nothing.
1020
+ # #788 rounds 6–7 — the per-file stream is kept apart from the run-level streams appended below,
1021
+ # because `IncrementalSession` must cache ONLY what per-file analysis produced (#per_file_diagnostics),
1022
+ # and the reader is exposed SEVERITY-RESOLVED and sliced to this run's targets. The cache serves a
1023
+ # reused file without re-stamping, so a raw row would resurrect a rule the profile resolves to `:off`
1024
+ # (`static.value-use.void` ships off on the default profile) and serve the authored severity where an
1025
+ # override re-stamps it; and a pool backend folds `.rigor.yml`-positioned prepare / pool-degraded rows
1026
+ # into the same return, which the slice drops. The run's own stream is stamped once, at the end.
1027
+ diagnostics += analyze_targets(targets, environment: environment, project_files: expansion.fetch(:files))
989
1028
  # ADR-103 WD12 — the effect fixpoint, in the post-pool aggregation slot beside the conformance
990
1029
  # results. Graph-only over a finite lattice, so it is a plain worklist to a true fixpoint; it
991
1030
  # contributes NO diagnostics and its result leaves through `#effect_table`, never through the
@@ -993,10 +1032,14 @@ module Rigor
993
1032
  close_effect_graph
994
1033
  diagnostics += @diagnostic_aggregator.rbs_quarantined_signature_diagnostics
995
1034
  diagnostics += @diagnostic_aggregator.rbs_environment_build_failed_diagnostics
996
- # Issue #696 — after its env-wide twin and before the synthesized-namespace notice: the three
1035
+ # Issue #696 — after its env-wide twin and before the synthesized-namespace notice: the four
997
1036
  # `rbs.coverage.*` build conditions surface widest-consequence first, and their relative order is
998
1037
  # the diagnostic output contract.
999
1038
  diagnostics += @diagnostic_aggregator.rbs_definition_build_failed_diagnostics
1039
+ # Issue #784 — last of the four, deliberately: it loses only the IMPLICIT HKT registrations a
1040
+ # `type` alias would have contributed, never a class's own declared method surface, so it is the
1041
+ # narrowest-consequence rung on the same ladder.
1042
+ diagnostics += @diagnostic_aggregator.rbs_hkt_scan_failed_diagnostics
1000
1043
  diagnostics += @diagnostic_aggregator.rbs_synthesized_namespace_diagnostics
1001
1044
  diagnostics += @diagnostic_aggregator.conforms_to_diagnostics
1002
1045
  diagnostics += @diagnostic_aggregator.rbs_extended_reporter_diagnostics
@@ -1004,6 +1047,21 @@ module Rigor
1004
1047
  diagnostics + @diagnostic_aggregator.source_rbs_synthesis_diagnostics
1005
1048
  end
1006
1049
 
1050
+ # #788 round 7 — runs per-file analysis over `targets` and exposes what it produced as
1051
+ # `#per_file_diagnostics`: the `analyze_files` return, stamped with the same severity profile the run's
1052
+ # own stream gets (`SeverityStamp` drops `:off` rows and re-stamps overrides, and the per-file cache never
1053
+ # re-stamps), sliced to the rows positioned at the targets. Returns the RAW return for the run's stream,
1054
+ # which is stamped once, at the end of `#run_analysis`.
1055
+ def analyze_targets(targets, environment:, project_files:)
1056
+ raw = @pool_coordinator.analyze_files(targets, environment: environment, project_files: project_files)
1057
+ analysed = targets.to_set
1058
+ @per_file_diagnostics = @diagnostic_aggregator.apply_severity_profile(raw)
1059
+ .select { |diagnostic| analysed.include?(diagnostic.path) }
1060
+ .freeze
1061
+ raw
1062
+ end
1063
+ private :analyze_targets
1064
+
1007
1065
  # ADR-67 WD6a — the check-walk parameter-inference pre-pass. Populates `@project_param_inferred_types`
1008
1066
  # (read by `project_scope_seed_tables`) with the call-site union of every undeclared parameter, running
1009
1067
  # ONE round (a single hop of call-site → param typing; the protection scan's three-round fixpoint stays a
@@ -1427,7 +1485,7 @@ module Rigor
1427
1485
  # indexes, prepare-diagnostic snapshot, and the four end-of-pass snapshots) is reached through reader
1428
1486
  # procs so each collaborator observes the live ivar value at call time without a back-reference
1429
1487
  # cycle. The reporter accumulators and the {RunSnapshots} sink are shared mutable instances.
1430
- def build_collaborators # rubocop:disable Metrics/MethodLength
1488
+ def build_collaborators # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
1431
1489
  @pre_passes = ProjectPrePasses.new(
1432
1490
  configuration: @configuration, cache_store: @cache_store, buffer: @buffer,
1433
1491
  plugin_requirer: @plugin_requirer, pool_mode: -> { pool_mode? }
@@ -1462,6 +1520,7 @@ module Rigor
1462
1520
  quarantined_signatures_snapshot: -> { @snapshots.quarantined_signatures },
1463
1521
  env_build_failure_snapshot: -> { @snapshots.env_build_failure },
1464
1522
  definition_build_failures_snapshot: -> { @snapshots.definition_build_failures },
1523
+ hkt_scan_failure_snapshot: -> { @snapshots.hkt_scan_failure },
1465
1524
  conformance_results_snapshot: -> { @snapshots.conformance_results }
1466
1525
  )
1467
1526
  end
@@ -1876,7 +1935,7 @@ module Rigor
1876
1935
  path: path,
1877
1936
  line: 1,
1878
1937
  column: 1,
1879
- message: "internal analyzer error: #{e.class}: #{e.message}",
1938
+ message: CrashSignature.check_rule_message(e),
1880
1939
  severity: :error
1881
1940
  )
1882
1941
  ]