rigortype 0.4.3 → 0.4.4

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +16 -0
  5. data/docs/manual/02-cli-reference.md +4 -3
  6. data/docs/manual/03-configuration.md +16 -5
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/07-plugins.md +1 -1
  9. data/docs/manual/10-mcp-server.md +32 -3
  10. data/docs/manual/18-removing-dead-code.md +11 -1
  11. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  12. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  13. data/exe/rigor +12 -17
  14. data/lib/rigor/analysis/check_rules/rule_ids.rb +57 -0
  15. data/lib/rigor/analysis/check_rules.rb +46 -36
  16. data/lib/rigor/analysis/incremental.rb +12 -0
  17. data/lib/rigor/analysis/incremental_session.rb +121 -21
  18. data/lib/rigor/analysis/reachability/graph.rb +23 -3
  19. data/lib/rigor/analysis/reachability/scan.rb +51 -9
  20. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  21. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  22. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  26. data/lib/rigor/analysis/runner.rb +35 -9
  27. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  28. data/lib/rigor/analysis/worker_session.rb +57 -1
  29. data/lib/rigor/cache/descriptor.rb +40 -6
  30. data/lib/rigor/cache/incremental_snapshot.rb +20 -4
  31. data/lib/rigor/cli/mcp_command.rb +1 -1
  32. data/lib/rigor/config_audit.rb +111 -15
  33. data/lib/rigor/configuration/dependencies.rb +19 -11
  34. data/lib/rigor/environment/rbs_loader.rb +46 -10
  35. data/lib/rigor/inference/block_call_timing.rb +5 -0
  36. data/lib/rigor/inference/captured_locals.rb +26 -3
  37. data/lib/rigor/inference/definer_resolution.rb +16 -7
  38. data/lib/rigor/inference/expression_typer.rb +59 -4
  39. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  40. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  41. data/lib/rigor/inference/module_function_state.rb +80 -13
  42. data/lib/rigor/inference/narrowing.rb +184 -68
  43. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  44. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  45. data/lib/rigor/inference/scope_indexer.rb +439 -70
  46. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  47. data/lib/rigor/inference/statement_evaluator.rb +84 -29
  48. data/lib/rigor/lens/anchor.rb +3 -3
  49. data/lib/rigor/lens/contract_reader.rb +15 -0
  50. data/lib/rigor/lens/document.rb +2 -2
  51. data/lib/rigor/lens/file_reader.rb +442 -73
  52. data/lib/rigor/lens/members.rb +65 -15
  53. data/lib/rigor/lens/run.rb +44 -2
  54. data/lib/rigor/lens.rb +2 -1
  55. data/lib/rigor/mcp/server.rb +61 -20
  56. data/lib/rigor/plugin/box_probe.rb +154 -7
  57. data/lib/rigor/project_environment.rb +6 -3
  58. data/lib/rigor/scope.rb +11 -6
  59. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  60. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  61. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  62. data/lib/rigor/sig_gen/writer.rb +47 -34
  63. data/lib/rigor/version.rb +1 -1
  64. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  65. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  66. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  67. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  68. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  69. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  70. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  71. metadata +4 -1
@@ -46,7 +46,8 @@ module Rigor
46
46
  synthetic_method_index:, project_patched_methods:,
47
47
  analyze_file:, project_scope_seed: -> { {} }, record_dependencies: false,
48
48
  restored_run_level_rows: nil, template_units: -> { TemplateUnits.empty },
49
- feature_sources: -> { {} }, plugin_prepare: -> { [[], []] })
49
+ feature_sources: -> { {} }, plugin_prepare: -> { [[], [], []] },
50
+ prepare_coordinator_plugins: -> { [] })
50
51
  @configuration = configuration
51
52
  @cache_store = cache_store
52
53
  @explain = explain
@@ -66,6 +67,12 @@ module Rigor
66
67
  # The de-duplication has to happen here, on the parent, because each fork worker owns its own
67
68
  # plugin instances and therefore its own `@run_disclosures` table.
68
69
  @collected_run_disclosures = {}
70
+ # Issue #1900 — the trust-policy refusals ({Analysis::WorkerSession#drain_trust_refusals}) a pool run
71
+ # collected from the pre-fork parent session and every worker, keyed by plugin id and summed. The
72
+ # coordinator-side registry, which {DiagnosticAggregator#plugin_trust_refusal_diagnostics} also
73
+ # reads, runs neither `#prepare` nor per-file analysis on a pooled run, so without this table a
74
+ # refusal recorded in `--workers 0` vanished in `--workers N`.
75
+ @collected_trust_refusals = {}
69
76
  @collect_stats = collect_stats
70
77
  @buffer = buffer
71
78
  @feature_sources = feature_sources
@@ -84,10 +91,15 @@ module Rigor
84
91
  # inherits the same compiled bytes.
85
92
  @template_units_reader = template_units
86
93
  @analyze_file = analyze_file
87
- # Issue #1541 — `() -> [rows, disclosure records]`, a `#prepare` run on a throwaway registry. Read
88
- # only by {#serve_empty_closure}: a pooled run's `#prepare` happens inside its workers, and an
89
- # empty closure dispatches none.
94
+ # Issue #1541 — `() -> [rows, disclosure records, trust refusals]`, a `#prepare` run on a throwaway
95
+ # registry. Read only by {#serve_empty_closure}: a pooled run's `#prepare` happens inside its
96
+ # workers, and an empty closure dispatches none.
90
97
  @plugin_prepare = plugin_prepare
98
+ # Issue #1900 — `() -> rows`, `#prepare` run on the coordinator-side registry itself. Read only by
99
+ # {#analyze_files_sequentially_fallback}, which analyses on that registry: the pre-pass skips
100
+ # `#prepare` whenever `pool_mode?`, so without this the fallback ran every plugin unprepared.
101
+ @prepare_coordinator_plugins = prepare_coordinator_plugins
102
+ @coordinator_plugins_prepared = false
91
103
  # Issues #796 / #794 — the previous full run's run-level rows, handed down by
92
104
  # {Analysis::IncrementalSession} on a NARROWED run only (a recheck's closure, a
93
105
  # `--verify-incremental` partition). A full run leaves this nil and derives everything itself.
@@ -123,6 +135,27 @@ module Rigor
123
135
  end
124
136
  end
125
137
 
138
+ # Issue #1900 — the trust refusals a pool run collected, one `{plugin_id:, count:, first_path:,
139
+ # nearest_root:}` per plugin, in first-merge order. Empty on a sequential run, where the runner's own
140
+ # registry made every read.
141
+ def collected_trust_refusals
142
+ @collected_trust_refusals.values
143
+ end
144
+
145
+ # Issue #1900 — folds one session's / one worker payload's refusal drain in: counts add, and the
146
+ # first path stays the one merged first. Callers merge in the order `--workers 0` would have made the
147
+ # reads — the parent's (or one worker's) `#prepare`, then the files in analysis order — so the summed
148
+ # row names the same first path the sequential run does, except where a drain's own first path is
149
+ # not this merge's to choose (see {DiagnosticAggregator#plugin_trust_refusal_diagnostics}).
150
+ def merge_trust_refusals(records)
151
+ Array(records).each do |record|
152
+ plugin_id = record[:plugin_id]
153
+ existing = @collected_trust_refusals[plugin_id]
154
+ @collected_trust_refusals[plugin_id] =
155
+ existing ? existing.merge(count: existing[:count] + record[:count]) : record
156
+ end
157
+ end
158
+
126
159
  # ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`. Editor mode (`buffer:` non-nil)
127
160
  # silently overrides pool mode to sequential: per design § "Ractor pool mode", the pool's warm-up
128
161
  # cost dominates one-file wall time, so the pool gains nothing on a per-buffer invocation. The
@@ -235,8 +268,9 @@ module Rigor
235
268
  # Issue #1541 — the pooled empty closure's `#prepare`: the rows go to the caller, the run-scoped
236
269
  # registrations into the table the workers' payloads would have filled.
237
270
  def pooled_prepare_rows
238
- rows, records = @plugin_prepare.call
271
+ rows, records, refusals = @plugin_prepare.call
239
272
  merge_run_disclosures(records)
273
+ merge_trust_refusals(refusals)
240
274
  Array(rows)
241
275
  end
242
276
 
@@ -639,13 +673,15 @@ module Rigor
639
673
  required_features: features
640
674
  )
641
675
  main = Ractor.main
642
- main.send([:prepare, session.prepare_diagnostics])
676
+ # Issue #1900 — each message carries the refusals made since the previous one, so the coordinator
677
+ # can count ONE worker's `#prepare` (every worker repeats it) and sum the files in file order.
678
+ main.send([:prepare, session.prepare_diagnostics, session.drain_trust_refusals])
643
679
 
644
680
  loop do
645
681
  msg = Ractor.receive
646
682
  break if msg.nil?
647
683
 
648
- main.send([:file, msg, session.analyze(msg)])
684
+ main.send([:file, msg, session.analyze(msg), session.drain_trust_refusals])
649
685
  end
650
686
 
651
687
  # Issue #1051 — sent with `:done`, not with `:prepare`: this backend builds no parent session,
@@ -664,6 +700,8 @@ module Rigor
664
700
  pool.each { |worker| worker.send(nil) }
665
701
 
666
702
  prepare_diagnostics = nil
703
+ prepare_refusals = nil
704
+ refusals_by_path = {}
667
705
  results_by_path = {}
668
706
  terminated = 0
669
707
 
@@ -681,9 +719,11 @@ module Rigor
681
719
 
682
720
  case message.first
683
721
  when :prepare
684
- prepare_diagnostics ||= message.last
722
+ prepare_diagnostics ||= message[1]
723
+ prepare_refusals ||= message[2]
685
724
  when :file
686
725
  results_by_path[message[1]] = message[2]
726
+ refusals_by_path[message[1]] = message[3]
687
727
  when :done
688
728
  merge_worker_reporters(message[1])
689
729
  merge_run_disclosures(message[2])
@@ -702,6 +742,8 @@ module Rigor
702
742
  # it did. Re-analysed in process, exactly as the fork backend re-analyses a dead child's slice.
703
743
  degraded = files.reject { |path| results_by_path.key?(path) }
704
744
  reanalyze_degraded_in_process(degraded, results_by_path, source_files: source_files)
745
+ merge_trust_refusals(prepare_refusals)
746
+ files.each { |path| merge_trust_refusals(refusals_by_path[path]) }
705
747
 
706
748
  diagnostics = Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
707
749
  degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "ractor"))
@@ -795,23 +837,34 @@ module Rigor
795
837
  # `merge_run_disclosures` drops it as a duplicate; only a disclosure a worker registers while
796
838
  # analysing its own slice adds anything.
797
839
  merge_run_disclosures(session.drain_run_disclosures)
840
+ # Issue #1900 — the parent's `#prepare` refusals, drained BEFORE the fork: every child inherits the
841
+ # session's record of what it already reported and drains only its own slice's, so the prepare-time
842
+ # refusal is counted once, as `--workers 0` counts it, rather than once per worker.
843
+ merge_trust_refusals(session.drain_trust_refusals)
798
844
  snapshot_fork_pool_stats(session) if @collect_stats
799
845
 
800
846
  worker_count = [@workers, files.size].min
801
847
  slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
802
848
  results_by_path = {}
849
+ # Issue #1900 — each surviving child's refusal drain by slice index, and each re-analysed file's by
850
+ # path, merged below in file order so the first refused path is the one `--workers 0` names.
851
+ refusals_by_slice = {}
852
+ refusals_by_path = {}
803
853
 
804
854
  degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
805
855
  children = slices.each_with_index.map do |slice, index|
806
856
  out_path = File.join(tmpdir, "worker-#{index}")
807
857
  { pid: fork { run_fork_worker(session, slice, out_path) },
808
- slice: slice, out_path: out_path }
858
+ slice: slice, out_path: out_path, index: index }
809
859
  end
810
- collect_fork_results(children, results_by_path)
860
+ collect_fork_results(children, results_by_path, refusals_by_slice: refusals_by_slice)
811
861
  end
812
862
 
813
863
  unless degraded.empty?
814
- degraded.each { |path| results_by_path[path] = session.analyze(path) }
864
+ degraded.each do |path|
865
+ results_by_path[path] = session.analyze(path)
866
+ refusals_by_path[path] = session.drain_trust_refusals
867
+ end
815
868
  merge_worker_reporters(session.drain_reporters)
816
869
  # The degraded slice ran on the parent session, so a disclosure it registered lives there.
817
870
  merge_run_disclosures(session.drain_run_disclosures)
@@ -821,11 +874,25 @@ module Rigor
821
874
  @collected_effects.merge!(session.drain_effects) if @record_effects
822
875
  end
823
876
 
877
+ merge_slice_refusals(slices, refusals_by_slice, refusals_by_path)
824
878
  diagnostics = Array(session.prepare_diagnostics) +
825
879
  files.flat_map { |path| results_by_path.fetch(path, []) }
826
880
  degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "fork"))
827
881
  end
828
882
 
883
+ # Issue #1900 — a surviving child's drain covers its whole slice; a degraded slice was re-analysed on
884
+ # the parent and drained file by file. Slices are contiguous runs of `files`, so walking them in index
885
+ # order merges every refusal in the order the sequential run made it.
886
+ def merge_slice_refusals(slices, refusals_by_slice, refusals_by_path)
887
+ slices.each_with_index do |slice, index|
888
+ if refusals_by_slice.key?(index)
889
+ merge_trust_refusals(refusals_by_slice[index])
890
+ else
891
+ slice.each { |path| merge_trust_refusals(refusals_by_path[path]) }
892
+ end
893
+ end
894
+ end
895
+
829
896
  # Child-process body for {#analyze_files_in_fork_pool}. Analyses the slice with the
830
897
  # copy-on-write-inherited session and writes the Marshal'd payload to `out_path`. `exit!` skips
831
898
  # `at_exit` / stdio flush — the payload is already durable on disk by then.
@@ -840,7 +907,8 @@ module Rigor
840
907
  results = slice.to_h { |path| [path, session.analyze(path)] }
841
908
  payload = { results: results, reporters: session.drain_reporters,
842
909
  dependencies: session.drain_dependencies,
843
- disclosures: session.drain_run_disclosures }
910
+ disclosures: session.drain_run_disclosures,
911
+ refusals: session.drain_trust_refusals }
844
912
  # ADR-103 WD13 — the effects slot exists only on a collecting run, so a normal `--workers N` run
845
913
  # marshals exactly the payload it marshalled before the feature existed.
846
914
  payload[:effects] = session.drain_effects if @record_effects
@@ -864,7 +932,9 @@ module Rigor
864
932
 
865
933
  # Waits for every forked child, merges each successful payload into `results_by_path`, and returns
866
934
  # the file paths whose worker exited abnormally (for in-process degrade).
867
- def collect_fork_results(children, results_by_path)
935
+ # @param refusals_by_slice — issue #1900 — receives each successful payload's trust-refusal drain
936
+ # under the child's slice index, for the caller to merge in file order; nil merges it at once.
937
+ def collect_fork_results(children, results_by_path, refusals_by_slice: nil)
868
938
  degraded = []
869
939
  children.each do |child|
870
940
  _, status = Process.waitpid2(child[:pid])
@@ -873,6 +943,8 @@ module Rigor
873
943
  results_by_path.merge!(payload.fetch(:results))
874
944
  merge_worker_reporters(payload.fetch(:reporters))
875
945
  merge_run_disclosures(payload.fetch(:disclosures, []))
946
+ refusals = payload.fetch(:refusals, [])
947
+ refusals_by_slice ? refusals_by_slice[child[:index]] = refusals : merge_trust_refusals(refusals)
876
948
  @collected_dependencies.merge!(payload.fetch(:dependencies, {})) if @record_dependencies
877
949
  @collected_effects.merge!(payload.fetch(:effects, {})) if @record_effects
878
950
  else
@@ -957,7 +1029,15 @@ module Rigor
957
1029
  # @param source_files — issue #793 — the whole project, so this path's environment
958
1030
  # carries the same plugin-synthesized RBS the pool workers' would. It used to build over `[]`,
959
1031
  # i.e. with no synthesized RBS at all, even on a full run.
1032
+ #
1033
+ # Issue #1900 — it also runs `#prepare` on the coordinator-side registry it analyses with, BEFORE the
1034
+ # environment build as the sequential pre-pass does. A pooled run's pre-pass skips `#prepare` (the
1035
+ # workers run it), and this path has no workers, so every plugin used to analyse unprepared and its
1036
+ # prepare rows and prepare-time trust refusals were lost. The rows head the stream, where the pool
1037
+ # backends put their workers' prepare rows; the refusals land on the coordinator's own
1038
+ # {Plugin::IoBoundary}s, which {DiagnosticAggregator#plugin_trust_refusal_diagnostics} reads.
960
1039
  def analyze_files_sequentially_fallback(files, reason:, source_files: files)
1040
+ prepare_rows = prepare_coordinator_plugins_once
961
1041
  environment = build_runner_environment(source_files: source_files)
962
1042
  # Same set, same ORDER as `#analyze_files_sequentially`. The signature state used to be hand-copied
963
1043
  # here slot by slot, which dropped `synthesized-namespace` and every conformance row. The conformance
@@ -965,7 +1045,7 @@ module Rigor
965
1045
  # BEFORE the definition-build failures are read below, or a failure only that scan reaches is lost.
966
1046
  snapshot_project_signature_state(environment)
967
1047
  snapshot_effect_annotation_carrier(environment.rbs_loader)
968
- diagnostics = files.flat_map { |path| @analyze_file.call(path, environment) }
1048
+ diagnostics = prepare_rows + files.flat_map { |path| @analyze_file.call(path, environment) }
969
1049
  loader = environment.rbs_loader
970
1050
  # Issue #696 — this path analyses on the coordinator, so its loader IS the one that reached the
971
1051
  # failing build, and it must snapshot the same slot the two real paths do. Reached whenever
@@ -987,6 +1067,15 @@ module Rigor
987
1067
  )
988
1068
  end
989
1069
 
1070
+ # Issue #1900 — `#prepare` on the coordinator-side registry, at most once per run: a second call would
1071
+ # re-publish every cross-plugin fact into the shared fact store.
1072
+ def prepare_coordinator_plugins_once
1073
+ return [] if @coordinator_plugins_prepared
1074
+
1075
+ @coordinator_plugins_prepared = true
1076
+ Array(@prepare_coordinator_plugins.call)
1077
+ end
1078
+
990
1079
  def merge_worker_reporters(drained)
991
1080
  merge_rbs_extended_reporter(drained.fetch(:rbs_extended))
992
1081
  drained.fetch(:boundary_cross).each do |entry|
@@ -1081,8 +1170,14 @@ module Rigor
1081
1170
  def record_definition_build_failures(failures)
1082
1171
  return if failures.nil? || failures.empty?
1083
1172
 
1173
+ # Issue #1902 — one tuple per class, chosen by {Environment::RbsLoader.definition_build_detail_order}
1174
+ # rather than by arrival: a fresh record and a replayed one (or two workers' records) for the same
1175
+ # class can name different failures, and the earlier one used to win.
1084
1176
  @snapshots.definition_build_failures =
1085
- (@snapshots.definition_build_failures + failures).uniq(&:first).freeze
1177
+ (@snapshots.definition_build_failures + failures)
1178
+ .group_by(&:first)
1179
+ .map { |_, candidates| candidates.min_by { |c| Environment::RbsLoader.definition_build_detail_order(c) } }
1180
+ .freeze
1086
1181
  end
1087
1182
 
1088
1183
  # Issue #784 — first-wins, unlike {#record_definition_build_failures}'s accumulate-and-dedup. That
@@ -8,6 +8,7 @@ require_relative "../../inference/synthetic_method_scanner"
8
8
  require_relative "../../inference/project_patched_scanner"
9
9
  require_relative "../dependency_source_inference"
10
10
  require_relative "../project_scan"
11
+ require_relative "../worker_session"
11
12
  require_relative "../template_units"
12
13
  require_relative "../template_unit_collector"
13
14
 
@@ -256,11 +257,15 @@ module Rigor
256
257
  # Issue #1541 — what a pooled run's workers would have produced from `#prepare`, for the coordinator's
257
258
  # empty-closure branch, which dispatches none: the `#prepare` error rows and the run-scoped
258
259
  # registrations (`#disclose_once` / `#emit_once`) made there. It runs on a THROWAWAY registry, so
259
- # nothing `#prepare` records on a plugin instance (a trust refusal, say) reaches the coordinator-side
260
- # instances {DiagnosticAggregator} reads — a pooled cold run never shows those either.
261
- # Returns the rows first, then the disclosure records.
260
+ # nothing `#prepare` records on a plugin instance reaches the coordinator-side instances
261
+ # {DiagnosticAggregator} reads; issue #1900 — the trust refusals are therefore handed back too, in
262
+ # the shape {Analysis::WorkerSession#drain_trust_refusals} gives a pooled cold run's.
263
+ # Returns the rows first, then the disclosure records, then the refusals.
262
264
  def prepare_throwaway_registry
263
265
  registry = load_plugins
266
+ # Issue #1900 — `#init` already ran (in `load_plugins`) and its refusals are the coordinator-side
267
+ # registry's to report, so only what `#prepare` refuses from here on is handed back.
268
+ baseline = WorkerSession.refusal_counts(registry)
264
269
  rows = plugin_prepare_diagnostics(registry)
265
270
  records = registry.plugins.flat_map do |plugin|
266
271
  plugin_id = safe_plugin_id(plugin)
@@ -268,7 +273,14 @@ module Rigor
268
273
  rescue StandardError
269
274
  []
270
275
  end
271
- [rows, records]
276
+ [rows, records, trust_refusals(registry, baseline)]
277
+ end
278
+
279
+ # Issue #1900 — `#prepare` on `plugin_registry` itself, returning its error rows. For the pool
280
+ # coordinator's sequential fallback, which analyses on the coordinator-side registry that {#run}
281
+ # left unprepared because the run is in pool mode.
282
+ def prepare_registry(plugin_registry)
283
+ plugin_prepare_diagnostics(plugin_registry)
272
284
  end
273
285
 
274
286
  # Returns the per-run shared `Plugin::FactStore` instance. All loaded plugins share this store
@@ -282,6 +294,19 @@ module Rigor
282
294
 
283
295
  private
284
296
 
297
+ def trust_refusals(registry, baseline)
298
+ registry.plugins.filter_map do |plugin|
299
+ summary = plugin.io_boundary.refusal_summary
300
+ next nil if summary.nil?
301
+
302
+ plugin_id = safe_plugin_id(plugin)
303
+ delta = summary[:count] - baseline.fetch(plugin_id, 0)
304
+ summary.merge(count: delta, plugin_id: plugin_id) if delta.positive?
305
+ rescue StandardError
306
+ nil
307
+ end
308
+ end
309
+
285
310
  def pool_mode?
286
311
  @pool_mode_reader.call
287
312
  end
@@ -297,7 +297,8 @@ module Rigor
297
297
  record_dependencies: false, record_self_calls: false, analyze_only: nil,
298
298
  seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
299
299
  discovery_seed: nil, no_tolerated_effects: false,
300
- restored_run_level_rows: nil, served_per_file_diagnostics: nil, on_file_indexed: nil)
300
+ restored_run_level_rows: nil, served_per_file_diagnostics: nil, served_effect_collections: nil,
301
+ on_file_indexed: nil)
301
302
  @configuration = configuration
302
303
  # ADR-113 WD2 — `rigor lens` reads a target's seeded index here: called with `(path, parse_result, scope,
303
304
  # index)` for every file this process indexes, after its rules ran and inside the run window, so the reader
@@ -311,6 +312,10 @@ module Rigor
311
312
  # narrowed run does not analyse, spliced into this run's per-file stream by
312
313
  # {#interleave_served_diagnostics}. Empty on every other run.
313
314
  @served_per_file_diagnostics = served_per_file_diagnostics || {}
315
+ # Issue #1898 — `{path => FileCollection}` the {IncrementalSession} serves for the same unanalysed files, so the
316
+ # effect graph this run closes (and the envelope pass judges) spans the whole project, not just the closure.
317
+ # Never part of {#effect_collections_by_path}, which stays what THIS run collected. Empty on every other run.
318
+ @served_effect_collections = served_effect_collections || {}
314
319
  @explain = explain
315
320
  @cache_store = enforce_read_only_cache(cache_store, buffer)
316
321
  @plugin_requirer = plugin_requirer
@@ -851,7 +856,8 @@ module Rigor
851
856
  analysis = serve_effect_collections(expansion, environment, key_descriptor, rbs_descriptor)
852
857
  computed = false
853
858
  diagnostics = @cache_store.fetch_or_validate(
854
- producer_id: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID, key_descriptor: key_descriptor,
859
+ producer_id: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID,
860
+ key_descriptor: RunCacheKey.with_roots(key_descriptor, expansion.fetch(:roots)),
855
861
  generation_cap: RunCacheKey::GENERATION_CAP
856
862
  ) do
857
863
  computed = true
@@ -1380,7 +1386,8 @@ module Rigor
1380
1386
 
1381
1387
  def build_run_dependency_descriptor(expansion, rbs_descriptor)
1382
1388
  entries = analyzed_file_entries(expansion) + discovery_file_entries(expansion) +
1383
- pre_eval_file_entries + template_unit_file_entries + rbs_descriptor.files
1389
+ pre_eval_file_entries + template_unit_file_entries + rbs_descriptor.files +
1390
+ root_presence_entries(expansion)
1384
1391
  # #979 — the signature ROOTS' listings, one glob row each. `rbs_descriptor.files` covers only the
1385
1392
  # `.rbs` files that existed while the run read them, so without these a signature file written after
1386
1393
  # the run left the slot validating fresh and the warm run answered without it.
@@ -1391,6 +1398,20 @@ module Rigor
1391
1398
  Cache::Descriptor.new(files: entries, globs: globs.uniq)
1392
1399
  end
1393
1400
 
1401
+ # #1962 / #1555 — a kind row per path the run's answer depends on the presence and kind of, not only its
1402
+ # bytes. An analysis root: its path errors (`no such file or directory`, `not a Ruby file`) answer to whether
1403
+ # it exists and whether it is a directory or a file, while what a directory root holds is the analysed-file
1404
+ # set the key carries. A `pre_eval:` entry: `pre-eval.file-not-found` answers to whether it is a file (one
1405
+ # that is has its `:stat` row from {#pre_eval_file_entries}). A project signature root, configured or the
1406
+ # auto-detected `sig`: the loader reads a directory and a file differently, and #979's listing rows cover a
1407
+ # file appearing under a root that existed, not the root appearing. A kind row rather than an existence row,
1408
+ # because a directory replaced by a file of the same name leaves an existence row fresh. The `--incremental`
1409
+ # slot records existence rows for the same paths ({#incremental_slot_rows}, {#baseline_dependency_rows}).
1410
+ def root_presence_entries(expansion)
1411
+ (expansion.fetch(:roots) + @configuration.pre_eval + signature_root_candidates)
1412
+ .uniq.map { |path| Cache::Descriptor::FileEntry.kind(path: path) }
1413
+ end
1414
+
1394
1415
  # Every file a plugin read or probed through its `IoBoundary` (#577 / #613), and every directory it listed
1395
1416
  # (#954), as the boundaries stand when asked.
1396
1417
  def plugin_boundary_rows
@@ -1580,7 +1601,7 @@ module Rigor
1580
1601
  end
1581
1602
  end
1582
1603
  end
1583
- private :plugin_boundary_rows, :producer_watch_glob_entries, :existence_entries,
1604
+ private :plugin_boundary_rows, :producer_watch_glob_entries, :existence_entries, :root_presence_entries,
1584
1605
  :analysis_reads_attributed?, :signature_root_candidates, :discovery_glob_entries,
1585
1606
  :unanalysed_pre_eval_entries, :signature_rows, :within?
1586
1607
 
@@ -1907,8 +1928,10 @@ module Rigor
1907
1928
  # The run's per-file effect collections in sorted path order — the sequential half merged with the
1908
1929
  # pool's, exactly as {#file_dependencies} reconciles its two halves. Sorted, because the fold below
1909
1930
  # it must not depend on pool-completion order.
1931
+ # Issue #1898 — a narrowed incremental run adds the collections its session served for the files it did not
1932
+ # analyse, under this run's own for any path both name.
1910
1933
  def effect_collections
1911
- effect_collections_by_path.sort_by { |path, _| path.to_s }.map(&:last)
1934
+ @served_effect_collections.merge(effect_collections_by_path).sort_by { |path, _| path.to_s }.map(&:last)
1912
1935
  end
1913
1936
 
1914
1937
  # Editor mode § "Scope choice — option A". Under `buffer:` non-nil the per-file analysis emits
@@ -2024,7 +2047,8 @@ module Rigor
2024
2047
  analyze_file: ->(path, environment) { analyze_file(path, environment) },
2025
2048
  restored_run_level_rows: @restored_run_level_rows,
2026
2049
  feature_sources: -> { required_feature_sources },
2027
- plugin_prepare: -> { @pre_passes.prepare_throwaway_registry }
2050
+ plugin_prepare: -> { @pre_passes.prepare_throwaway_registry },
2051
+ prepare_coordinator_plugins: -> { @pre_passes.prepare_registry(@plugin_registry) }
2028
2052
  )
2029
2053
  @diagnostic_aggregator = DiagnosticAggregator.new(
2030
2054
  configuration: @configuration,
@@ -2044,7 +2068,8 @@ module Rigor
2044
2068
  definition_build_failures_snapshot: -> { @snapshots.definition_build_failures },
2045
2069
  hkt_scan_failure_snapshot: -> { @snapshots.hkt_scan_failure },
2046
2070
  conformance_results_snapshot: -> { @snapshots.conformance_results },
2047
- pooled_run_disclosures: -> { @pool_coordinator.collected_run_disclosures }
2071
+ pooled_run_disclosures: -> { @pool_coordinator.collected_run_disclosures },
2072
+ pooled_trust_refusals: -> { @pool_coordinator.collected_trust_refusals }
2048
2073
  )
2049
2074
  end
2050
2075
 
@@ -2197,7 +2222,7 @@ module Rigor
2197
2222
  bad << [path, "no such file or directory"]
2198
2223
  end
2199
2224
  end
2200
- { files: files, errors: path_expansion_errors(bad, any_files: files.any?) }
2225
+ { files: files, roots: Array(paths).map(&:to_s), errors: path_expansion_errors(bad, any_files: files.any?) }
2201
2226
  end
2202
2227
 
2203
2228
  # A bad path *among valid ones* is a warn-and-skip — the run still does useful work, so `rigor check
@@ -2654,7 +2679,8 @@ module Rigor
2654
2679
  node_collectors: node_collectors,
2655
2680
  stated_runtime_ruby: @configuration.stated_runtime_ruby
2656
2681
  )
2657
- diagnostics + plugin_emitted_diagnostics(path, root, scope, node_results)
2682
+ plugin_rows = plugin_emitted_diagnostics(path, root, scope, node_results)
2683
+ diagnostics + CheckRules.filter_plugin_suppressed(plugin_rows, path: path, comments: parse_result.comments)
2658
2684
  end
2659
2685
 
2660
2686
  # ADR-24 slice 4a — runs `block` (the typing pass) with the self-call recorder active when either
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "check_rules/rule_ids"
3
4
  require_relative "diagnostic"
4
5
  require_relative "../configuration/severity_profile"
5
6
 
@@ -9,14 +10,46 @@ module Rigor
9
10
  # overrides and drops any diagnostic resolving to `:off`. Rules emit with their authored severity; the
10
11
  # profile is the final filter.
11
12
  #
13
+ # Issue #1895 — the `.rigor.yml` `disable:` list is applied here too, ahead of the profile, with the same
14
+ # token expansion and match per-file analysis uses ({CheckRules.config_disabled?}), but only to a built-in
15
+ # check-rule row (see {.check_rule_row?}): the rows the diagnostic policy already says `disable:` reaches. Most
16
+ # are filtered where they were produced; this catches the ones that are not (the `effect.annotations-unchecked`
17
+ # residual under `disable: [all]`) on every run shape (sequential, pool, `--incremental`, a warm cache hit).
18
+ # Issue #1953 — a plugin's `plugin.<id>.*` row answers here to its qualified id (`plugin.<id>.<rule>`) or to
19
+ # `plugin.<id>`, never to a bare id or `all`. The run-level rows outside the check-rule catalogue
20
+ # (`rbs.coverage.*`, `dynamic.dependency-source.*`, the plugin loader, `plugin_trust.*`, `pool-degraded`) keep
21
+ # answering to `severity_overrides:` only, by any spelling.
22
+ #
12
23
  # Extracted (ADR-87 WD4) so the miss path ({Runner::DiagnosticAggregator}) and the boot-slimming hit path
13
24
  # ({RunCacheProbe}, which never loads the aggregator) apply the IDENTICAL final filter — a warm HIT's
14
25
  # served diagnostics are byte-identical to what a full run would print.
15
26
  module SeverityStamp
16
27
  module_function
17
28
 
29
+ CHECK_RULES = CheckRules::ALL_RULES.to_set.freeze
30
+ private_constant :CHECK_RULES
31
+
18
32
  def apply(diagnostics, configuration)
19
- diagnostics.filter_map { |diagnostic| stamp(diagnostic, configuration) }
33
+ disabled = CheckRules.expand_rule_tokens(configuration.disabled_rules)
34
+ diagnostics.filter_map do |diagnostic|
35
+ next nil if config_disabled_row?(diagnostic, disabled)
36
+
37
+ stamp(diagnostic, configuration)
38
+ end
39
+ end
40
+
41
+ # A built-in check-rule row answers to the expanded list by its id or `all`; a plugin's `plugin.<id>.*` row
42
+ # by its qualified id or `plugin.<id>` only ({CheckRules.plugin_row_disabled?}, issue #1953). Every other
43
+ # row is out of the list's reach.
44
+ def config_disabled_row?(diagnostic, disabled)
45
+ return CheckRules.config_disabled?(diagnostic.rule, disabled) if check_rule_row?(diagnostic)
46
+
47
+ CheckRules.plugin_row_disabled?(diagnostic, disabled)
48
+ end
49
+
50
+ # A built-in check-rule row: its id is in the CheckRules catalogue and no plugin emitted it.
51
+ def check_rule_row?(diagnostic)
52
+ diagnostic.source_family == Diagnostic::DEFAULT_SOURCE_FAMILY && CHECK_RULES.include?(diagnostic.rule)
20
53
  end
21
54
 
22
55
  def stamp(diagnostic, configuration)
@@ -241,7 +241,8 @@ module Rigor
241
241
  node_collectors: node_collectors,
242
242
  stated_runtime_ruby: @configuration.stated_runtime_ruby
243
243
  )
244
- diagnostics += plugin_emitted_diagnostics(path, root, scope, node_results)
244
+ plugin_rows = plugin_emitted_diagnostics(path, root, scope, node_results)
245
+ diagnostics += CheckRules.filter_plugin_suppressed(plugin_rows, path: path, comments: parse_result.comments)
245
246
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
246
247
  rescue Errno::ENOENT => e
247
248
  [analyzer_error(path, e.message)]
@@ -336,6 +337,45 @@ module Rigor
336
337
  end
337
338
  end
338
339
 
340
+ # Issue #1900 — `{plugin id => refused-read count}` over `registry`'s boundaries right now: the baseline
341
+ # a later drain subtracts, so it reports only the refusals made after the baseline was taken.
342
+ def self.refusal_counts(registry)
343
+ registry.plugins.to_h do |plugin|
344
+ summary = begin
345
+ plugin.io_boundary.refusal_summary
346
+ rescue StandardError
347
+ nil
348
+ end
349
+ [safe_plugin_id(plugin), summary ? summary[:count] : 0]
350
+ end
351
+ end
352
+
353
+ def self.safe_plugin_id(plugin)
354
+ plugin.manifest.id
355
+ rescue StandardError
356
+ plugin.class.to_s
357
+ end
358
+
359
+ # Issue #1900 — the {Plugin::IoBoundary} trust-policy refusals this session's plugin instances made
360
+ # since the previous drain, one `{plugin_id:, count:, first_path:, nearest_root:}` per plugin that hit
361
+ # any, in registry order. Unlike {#drain_run_disclosures} each drain reports only what is new — the
362
+ # boundary keeps its running total and this session remembers how much of it was already reported —
363
+ # because the coordinator SUMS the drains: a fork child inherits the parent's `#prepare` refusals,
364
+ # and reporting them again would count them once per worker. `first_path` is the boundary's first
365
+ # refusal overall, which is the delta's own first path whenever nothing was reported before it, and
366
+ # the coordinator keeps the first-merged path otherwise. It is not baselined after `#init`, so a plugin
367
+ # whose `#init` refuses differently here than on the coordinator registry can have this session's
368
+ # `#init` path named. Marshal-clean and Ractor-copyable.
369
+ def drain_trust_refusals
370
+ return [] if @plugin_registry.empty?
371
+
372
+ @plugin_registry.plugins.filter_map do |plugin|
373
+ trust_refusal_delta(plugin)
374
+ rescue StandardError
375
+ nil
376
+ end
377
+ end
378
+
339
379
  private
340
380
 
341
381
  # Mirrors {Runner#seed_project_scope}: applies the cross-file pre-pass discovery tables the
@@ -402,6 +442,10 @@ module Rigor
402
442
  def run_plugin_prepare
403
443
  return [] if @plugin_registry.empty?
404
444
 
445
+ # Issue #1900 — per plugin id, how much of its boundary's count is already reported. Starts at what
446
+ # `#init` refused while the registry was materialised: `#init` runs on the coordinator-side registry
447
+ # too, which reports those reads itself, so counting them here as well would double them.
448
+ @reported_refusal_counts = self.class.refusal_counts(@plugin_registry)
405
449
  @plugin_registry.plugins.flat_map do |plugin|
406
450
  plugin.prepare(plugin.services)
407
451
  []
@@ -490,6 +534,18 @@ module Rigor
490
534
  )
491
535
  end
492
536
 
537
+ def trust_refusal_delta(plugin)
538
+ summary = plugin.io_boundary.refusal_summary
539
+ return nil if summary.nil?
540
+
541
+ plugin_id = safe_plugin_id(plugin)
542
+ delta = summary[:count] - @reported_refusal_counts.fetch(plugin_id, 0)
543
+ return nil unless delta.positive?
544
+
545
+ @reported_refusal_counts[plugin_id] = summary[:count]
546
+ summary.merge(count: delta, plugin_id: plugin_id)
547
+ end
548
+
493
549
  def safe_plugin_id(plugin)
494
550
  plugin.manifest.id
495
551
  rescue StandardError