rigortype 0.4.2 → 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 (128) 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 +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -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
 
@@ -59,7 +60,7 @@ module Rigor
59
60
  :unpositioned_mixins, :discovered_class_sources, :constant_values, :constant_sources, :constant_writes,
60
61
  :discovered_method_visibilities, :discovered_methods, :discovered_parameter_envelopes, :data_member_layouts,
61
62
  :struct_member_layouts, :discovered_deferred_ranges, :discovered_refinements, :siblings,
62
- :discovered_global_write_census
63
+ :discovered_global_write_census, :singleton_class_sources
63
64
  )
64
65
 
65
66
  # Internal: drives every EAGER project-wide pre-pass — the ones whose products feed the RBS
@@ -175,6 +176,8 @@ module Rigor
175
176
  # object's `extend`s reach `Narrowing` from a sibling file the way its `include`s already do.
176
177
  discovered_extends: def_index.fetch(:extends), unpositioned_mixins: def_index.fetch(:unpositioned_mixins),
177
178
  discovered_class_sources: def_index.fetch(:class_sources),
179
+ # Issue #1752 — the files opening `class << Const`, for the class-declaration diff alone.
180
+ singleton_class_sources: def_index.fetch(:singleton_class_sources, {}),
178
181
  # Issue #644 — the cross-file value-constant publication table and its write attribution.
179
182
  constant_values: def_index.fetch(:constant_values), constant_sources: def_index.fetch(:constant_sources),
180
183
  constant_writes: def_index.fetch(:constant_writes),
@@ -251,6 +254,35 @@ module Rigor
251
254
  registry
252
255
  end
253
256
 
257
+ # Issue #1541 — what a pooled run's workers would have produced from `#prepare`, for the coordinator's
258
+ # empty-closure branch, which dispatches none: the `#prepare` error rows and the run-scoped
259
+ # registrations (`#disclose_once` / `#emit_once`) made there. It runs on a THROWAWAY registry, so
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.
264
+ def prepare_throwaway_registry
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)
269
+ rows = plugin_prepare_diagnostics(registry)
270
+ records = registry.plugins.flat_map do |plugin|
271
+ plugin_id = safe_plugin_id(plugin)
272
+ Array(plugin.run_disclosure_records).map { |record| record.merge(plugin_id: plugin_id) }
273
+ rescue StandardError
274
+ []
275
+ end
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)
284
+ end
285
+
254
286
  # Returns the per-run shared `Plugin::FactStore` instance. All loaded plugins share this store
255
287
  # through their respective `Plugin::Services` (the same instance is threaded by
256
288
  # `Plugin::Loader.load`). Returns `nil` when no plugins are loaded.
@@ -262,6 +294,19 @@ module Rigor
262
294
 
263
295
  private
264
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
+
265
310
  def pool_mode?
266
311
  @pool_mode_reader.call
267
312
  end
@@ -297,8 +297,14 @@ 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)
300
+ restored_run_level_rows: nil, served_per_file_diagnostics: nil, served_effect_collections: nil,
301
+ on_file_indexed: nil)
301
302
  @configuration = configuration
303
+ # ADR-113 WD2 — `rigor lens` reads a target's seeded index here: called with `(path, parse_result, scope,
304
+ # index)` for every file this process indexes, after its rules ran and inside the run window, so the reader
305
+ # sees what `check` saw. A pooled file is indexed in a worker and never reaches it; the incremental session
306
+ # analyses forced targets with `workers: 0` for that reason.
307
+ @on_file_indexed = on_file_indexed
302
308
  # Issues #796 / #794 — the previous full run's run-level rows, supplied only by
303
309
  # {IncrementalSession} on a narrowed run; see {PoolCoordinator#replay_restored_run_level_rows}.
304
310
  @restored_run_level_rows = restored_run_level_rows
@@ -306,6 +312,10 @@ module Rigor
306
312
  # narrowed run does not analyse, spliced into this run's per-file stream by
307
313
  # {#interleave_served_diagnostics}. Empty on every other run.
308
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 || {}
309
319
  @explain = explain
310
320
  @cache_store = enforce_read_only_cache(cache_store, buffer)
311
321
  @plugin_requirer = plugin_requirer
@@ -431,6 +441,7 @@ module Rigor
431
441
  # Issue #1367 — the project's `Inference::GlobalWriteCensus`, which the `global.*` write rules read.
432
442
  @project_discovered_global_write_census = Set.new.freeze
433
443
  @project_discovered_class_sources = {}.freeze
444
+ @project_singleton_class_sources = {}.freeze
434
445
  # Issue #644 — the cross-file VALUE-constant publication table (`{qualified name => Type::Constant}`,
435
446
  # literal writes only) and its per-name write attribution. The first seeds `in_source_constants` on
436
447
  # every per-file scope so `FOO = :sym` in one file reads as `:sym` in another; the second seeds
@@ -648,8 +659,8 @@ module Rigor
648
659
  # subclasses (`subclass_of:<last segment>`).
649
660
  def class_declarations
650
661
  result = Hash.new { |hash, key| hash[key] = Set.new }
651
- @project_discovered_class_sources.each do |class_name, files|
652
- files.each { |file| result[file] << class_name }
662
+ [@project_discovered_class_sources, @project_singleton_class_sources].each do |sources|
663
+ sources.each { |class_name, files| files.each { |file| result[file] << class_name } }
653
664
  end
654
665
  Incremental.add_superclass_edges(result, @project_discovered_class_sources, @project_discovered_superclasses)
655
666
  result.transform_values(&:freeze).freeze
@@ -845,7 +856,8 @@ module Rigor
845
856
  analysis = serve_effect_collections(expansion, environment, key_descriptor, rbs_descriptor)
846
857
  computed = false
847
858
  diagnostics = @cache_store.fetch_or_validate(
848
- 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)),
849
861
  generation_cap: RunCacheKey::GENERATION_CAP
850
862
  ) do
851
863
  computed = true
@@ -1374,7 +1386,8 @@ module Rigor
1374
1386
 
1375
1387
  def build_run_dependency_descriptor(expansion, rbs_descriptor)
1376
1388
  entries = analyzed_file_entries(expansion) + discovery_file_entries(expansion) +
1377
- 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)
1378
1391
  # #979 — the signature ROOTS' listings, one glob row each. `rbs_descriptor.files` covers only the
1379
1392
  # `.rbs` files that existed while the run read them, so without these a signature file written after
1380
1393
  # the run left the slot validating fresh and the warm run answered without it.
@@ -1385,6 +1398,20 @@ module Rigor
1385
1398
  Cache::Descriptor.new(files: entries, globs: globs.uniq)
1386
1399
  end
1387
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
+
1388
1415
  # Every file a plugin read or probed through its `IoBoundary` (#577 / #613), and every directory it listed
1389
1416
  # (#954), as the boundaries stand when asked.
1390
1417
  def plugin_boundary_rows
@@ -1574,7 +1601,7 @@ module Rigor
1574
1601
  end
1575
1602
  end
1576
1603
  end
1577
- private :plugin_boundary_rows, :producer_watch_glob_entries, :existence_entries,
1604
+ private :plugin_boundary_rows, :producer_watch_glob_entries, :existence_entries, :root_presence_entries,
1578
1605
  :analysis_reads_attributed?, :signature_root_candidates, :discovery_glob_entries,
1579
1606
  :unanalysed_pre_eval_entries, :signature_rows, :within?
1580
1607
 
@@ -1748,6 +1775,8 @@ module Rigor
1748
1775
  @project_discovered_prepends = discovery.discovered_prepends
1749
1776
  @project_discovered_extends = discovery.discovered_extends
1750
1777
  @project_unpositioned_mixins = discovery.unpositioned_mixins
1778
+ # Issue #1752 — the files that open `class << Const`, read only by {#class_declarations}.
1779
+ @project_singleton_class_sources = discovery.singleton_class_sources
1751
1780
  end
1752
1781
 
1753
1782
  # Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
@@ -1804,10 +1833,8 @@ module Rigor
1804
1833
  # the pre-pass then saw two writers for every constant a file declares and withheld its publication (#644),
1805
1834
  # and every file was a discovered-not-analysed dependency of the run's cache entry.
1806
1835
  #
1807
- # The widening decision itself still compares the paths as spelled. `exclude:` patterns match a path as it
1808
- # is spelled, so until #1576 is fixed the configured absolute `lib` can discover files the argument `lib`
1809
- # excludes (a relative `lib/gen/**` pattern never matches `/abs/lib/gen/x.rb`), and treating the two as the
1810
- # same root would drop those files from discovery.
1836
+ # The widening decision itself compares the paths as spelled. `exclude:` patterns apply to both spellings
1837
+ # alike (#1576), so the configured absolute `lib` and the argument `lib` exclude the same files.
1811
1838
  def project_discovery_expansion(paths, expansion)
1812
1839
  return nil unless widen_discovery_to_project?(paths)
1813
1840
 
@@ -1901,8 +1928,10 @@ module Rigor
1901
1928
  # The run's per-file effect collections in sorted path order — the sequential half merged with the
1902
1929
  # pool's, exactly as {#file_dependencies} reconciles its two halves. Sorted, because the fold below
1903
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.
1904
1933
  def effect_collections
1905
- 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)
1906
1935
  end
1907
1936
 
1908
1937
  # Editor mode § "Scope choice — option A". Under `buffer:` non-nil the per-file analysis emits
@@ -2017,7 +2046,9 @@ module Rigor
2017
2046
  template_units: -> { template_units },
2018
2047
  analyze_file: ->(path, environment) { analyze_file(path, environment) },
2019
2048
  restored_run_level_rows: @restored_run_level_rows,
2020
- feature_sources: -> { required_feature_sources }
2049
+ feature_sources: -> { required_feature_sources },
2050
+ plugin_prepare: -> { @pre_passes.prepare_throwaway_registry },
2051
+ prepare_coordinator_plugins: -> { @pre_passes.prepare_registry(@plugin_registry) }
2021
2052
  )
2022
2053
  @diagnostic_aggregator = DiagnosticAggregator.new(
2023
2054
  configuration: @configuration,
@@ -2037,7 +2068,8 @@ module Rigor
2037
2068
  definition_build_failures_snapshot: -> { @snapshots.definition_build_failures },
2038
2069
  hkt_scan_failure_snapshot: -> { @snapshots.hkt_scan_failure },
2039
2070
  conformance_results_snapshot: -> { @snapshots.conformance_results },
2040
- 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 }
2041
2073
  )
2042
2074
  end
2043
2075
 
@@ -2063,7 +2095,8 @@ module Rigor
2063
2095
  )
2064
2096
  RunStats.new(
2065
2097
  wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
2066
- peak_rss_bytes: RunStats.peak_rss_bytes,
2098
+ # Issue #1529 — read lazily on the summary's first use, so a run that prints none never forks for it.
2099
+ peak_rss_probe: RunStats::CURRENT_PROCESS_PEAK_RSS,
2067
2100
  # #392 — the template units count too: they are files this run parsed and typed, and a stats line
2068
2101
  # that omitted them would under-report exactly the work a views-heavy project added.
2069
2102
  target_files: expansion.fetch(:files).size + template_units.paths.size,
@@ -2176,7 +2209,8 @@ module Rigor
2176
2209
  bad = []
2177
2210
  Array(paths).each do |path|
2178
2211
  if File.directory?(path)
2179
- files.concat(PathExpansion.directory_files(path, @configuration.exclude_patterns))
2212
+ files.concat(PathExpansion.directory_files(path, @configuration.exclude_patterns,
2213
+ base: @configuration.exclude_base))
2180
2214
  # Editor-mode bypass: the buffer's logical path is treated as a real `.rb` file regardless of
2181
2215
  # on-disk presence — `parse_source` reads bytes from the buffer's physical path. Common case: LSP
2182
2216
  # client editing a brand-new file.
@@ -2188,7 +2222,7 @@ module Rigor
2188
2222
  bad << [path, "no such file or directory"]
2189
2223
  end
2190
2224
  end
2191
- { 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?) }
2192
2226
  end
2193
2227
 
2194
2228
  # A bad path *among valid ones* is a warn-and-skip — the run still does useful work, so `rigor check
@@ -2591,10 +2625,11 @@ module Rigor
2591
2625
  # the rule is active or `record_self_calls:` opted in.
2592
2626
  index = nil
2593
2627
  self_call_record = with_self_call_recording(path) do
2594
- index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
2628
+ index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope, widen_clauses: true)
2595
2629
  end
2596
2630
  self_call_misses = self_call_record ? self_call_record.calls : []
2597
2631
  diagnostics = rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
2632
+ notify_file_indexed(path, parse_result, scope, index)
2598
2633
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
2599
2634
  rescue Errno::ENOENT => e
2600
2635
  [
@@ -2618,6 +2653,14 @@ module Rigor
2618
2653
  ]
2619
2654
  end
2620
2655
 
2656
+ # ADR-113 WD2 — the `on_file_indexed` observer. It reads; an error inside it never changes this file's
2657
+ # diagnostics, so it is contained here rather than reaching {#analyze_file_body}'s crash row.
2658
+ def notify_file_indexed(path, parse_result, scope, index)
2659
+ @on_file_indexed&.call(path, parse_result, scope, index)
2660
+ rescue StandardError
2661
+ nil
2662
+ end
2663
+
2621
2664
  # ADR-53 B4 — the built-in node collectors and the plugin node rules share ONE traversal of the file.
2622
2665
  # The collectors are built here (they need the completed `index`) and populated by the converged
2623
2666
  # plugin walk; `node_results` carries the per-plugin node-rule output. Both the built-in `diagnose`
@@ -2636,7 +2679,8 @@ module Rigor
2636
2679
  node_collectors: node_collectors,
2637
2680
  stated_runtime_ruby: @configuration.stated_runtime_ruby
2638
2681
  )
2639
- 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)
2640
2684
  end
2641
2685
 
2642
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)
@@ -228,7 +228,7 @@ module Rigor
228
228
  scope = @template_units.seed(
229
229
  seed_project_scope(Scope.empty(environment: @environment, source_path: path)), path
230
230
  )
231
- index = Inference::ScopeIndexer.index(root, default_scope: scope)
231
+ index = Inference::ScopeIndexer.index(root, default_scope: scope, widen_clauses: true)
232
232
  # ADR-53 B4 — built-in collectors + plugin node rules share one walk.
233
233
  node_collectors = CheckRules.build_node_collectors(path, index, root)
234
234
  node_results = node_rule_results_by_plugin(path, root, scope, node_collectors, index)
@@ -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
@@ -82,7 +82,12 @@ module Rigor
82
82
  # an unchanged file would fold as "no possible fact" where a cold walk records one.
83
83
  # v18: ADR-119 WD3 — the visibility walk fills `contested_discovered_method_visibilities`; a pre-18 bundle
84
84
  # carries it empty, so an unchanged file's conditional visibility would fold as certain.
85
- SCHEMA_VERSION = 18
85
+ # v19: #1555 / #1962 — the run-result dependency descriptor now carries a `:kind` row per analysis root,
86
+ # `pre_eval:` entry and project signature root (configured, or the auto-detected `sig`), so one that
87
+ # appears, vanishes or changes kind invalidates the warm entry. A
88
+ # pre-19 entry carries none of them and would validate fresh across exactly those edits, so cached
89
+ # entries must read as misses once and rebuild carrying the rows.
90
+ SCHEMA_VERSION = 19
86
91
 
87
92
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
88
93
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -96,7 +101,7 @@ module Rigor
96
101
  # It MUST NOT be placed in a descriptor used as a cache KEY (its value carries machine-local,
97
102
  # per-run-nondeterministic stat data); {RbsDescriptor.build}'s env-cache `files` therefore stay
98
103
  # `:digest`, while the {Runner} run-dependency descriptor and plugin {IoBoundary} reads use `:stat`.
99
- VALID_COMPARATORS = %i[digest stat mtime exists].freeze
104
+ VALID_COMPARATORS = %i[digest stat mtime kind exists].freeze
100
105
 
101
106
  # The two `:exists` values — `File.exist?`'s answer rendered with `to_s`, which is what
102
107
  # {Descriptor#file_entry_fresh?} compares the live answer against.
@@ -163,6 +168,32 @@ module Rigor
163
168
  new(path: path, comparator: :exists, value: PRESENT)
164
169
  end
165
170
 
171
+ # The four `:kind` values, in the order {Analysis::Runner}'s path expansion asks: a directory, a regular
172
+ # file, something else that exists (a FIFO, a socket), nothing (a missing path or a dangling symlink).
173
+ # Each predicate follows symlinks, as the expansion's do.
174
+ KIND_DIRECTORY = "directory"
175
+ KIND_FILE = "file"
176
+ KIND_OTHER = "other"
177
+ KIND_ABSENT = "absent"
178
+
179
+ # #1962 — the kind row: fresh while `path` is still the same kind of thing, stale once it appears,
180
+ # vanishes, or a directory and a file trade places. It is the row for an analysis root, whose
181
+ # diagnostics (`no such file or directory`, `not a Ruby file`) answer to exactly that and to nothing
182
+ # else about the path, and for a `pre_eval:` entry and a signature root: an existence row would read a
183
+ # directory replaced by a file of the same name as fresh (ADR-45 WD1b's bound), and what the path holds
184
+ # has rows of its own (the analysed-file set the key carries, a signature root's `.rbs` rows).
185
+ def self.kind(path:)
186
+ new(path: path, comparator: :kind, value: kind_of(path))
187
+ end
188
+
189
+ def self.kind_of(path)
190
+ if File.directory?(path) then KIND_DIRECTORY
191
+ elsif File.file?(path) then KIND_FILE
192
+ elsif File.exist?(path) then KIND_OTHER
193
+ else KIND_ABSENT
194
+ end
195
+ end
196
+
166
197
  # @return whether this row records the path as absent (see {.absent}).
167
198
  def absent?
168
199
  comparator == :exists && value == ABSENT
@@ -413,10 +444,11 @@ module Rigor
413
444
 
414
445
  # File-comparator strictness ordering. `:stat` is strictest (it carries the content digest AND a stat
415
446
  # tuple, so it validates content-authoritatively while short-circuiting on an unmoved stat); `:digest` is
416
- # deterministic across machines; `:mtime` is cheaper but local; `:exists` is the weakest signal. Ranking
417
- # `:stat` and `:digest` apart guarantees a path contributed under both never raises a value {Conflict}
418
- # (their `value` strings differ by construction) — the stricter one wins and its value is used.
419
- COMPARATOR_STRICTNESS = { stat: 3, digest: 2, mtime: 1, exists: 0 }.freeze
447
+ # deterministic across machines; `:mtime` is cheaper but local; `:kind` answers what the path is and
448
+ # `:exists` only whether it is there, the weakest signal. Ranking `:stat` and `:digest` apart guarantees a
449
+ # path contributed under both never raises a value {Conflict} (their `value` strings differ by
450
+ # construction) — the stricter one wins and its value is used.
451
+ COMPARATOR_STRICTNESS = { stat: 4, digest: 3, mtime: 2, kind: 1, exists: 0 }.freeze
420
452
  private_constant :COMPARATOR_STRICTNESS
421
453
 
422
454
  # Composes any number of descriptors into a single descriptor whose slots are the union of the inputs'
@@ -505,6 +537,8 @@ module Rigor
505
537
  FileDigest.stat_fresh?(entry.path, entry.value)
506
538
  when :mtime
507
539
  File.exist?(entry.path) && File.mtime(entry.path).to_i.to_s == entry.value
540
+ when :kind
541
+ FileEntry.kind_of(entry.path) == entry.value
508
542
  when :exists
509
543
  File.exist?(entry.path).to_s == entry.value
510
544
  else
@@ -59,6 +59,19 @@ module Rigor
59
59
  # `apps/` and `tool/` are not loaded by an analysis and stay out.
60
60
  SOURCE_DIRECTORIES = %w[lib plugins].freeze
61
61
 
62
+ # Engine DATA the analysis reads at runtime, relative to the gem root (issue #1492). A data edit moves
63
+ # diagnostics as surely as a `lib/` edit, yet the caches that key only on `signature_paths` plus this
64
+ # digest (the `--incremental` snapshot fingerprint, `Protection::MutationCache`) see nothing else.
65
+ #
66
+ # - YAML: `data/builtins` (`MethodCatalog::DATA_ROOT`) and `data/effects` (`Effects::Catalog`,
67
+ # `Effects::Registry`).
68
+ # - RBS: every `.rbs` under `data/` (`core_overlay`, `vendored_gem_sigs`, `capability_roles`,
69
+ # `gem_overlay`) plus each bundled plugin's `plugins/*/sig`. `RbsDescriptor` digests the same files
70
+ # into the run-result and env caches, but those are the only two consumers that do.
71
+ #
72
+ # Tooling-only trees (`data/oss-sweep`) are not read by an analysis and stay out.
73
+ DATA_GLOBS = %w[data/builtins/**/*.yml data/effects/**/*.yml data/**/*.rbs plugins/*/sig/**/*.rbs].freeze
74
+
62
75
  # The one directory whose absence means "this is not an engine tree". `plugins/` may legitimately be
63
76
  # missing (a slimmed install contributes nothing); a missing `lib/` would silently shrink the digest
64
77
  # to whatever else happened to be there, which is exactly the weakening this module forbids.
@@ -153,8 +166,9 @@ module Rigor
153
166
  !File.exist?(File.join(root, ".git"))
154
167
  end
155
168
 
156
- # A SHA-256 over every engine `.rb` file: its ROOT-RELATIVE path (so the digest survives moving or
157
- # re-cloning the checkout) followed by its bytes, in sorted path order.
169
+ # A SHA-256 over every engine `.rb` file and {DATA_GLOBS} data file: its ROOT-RELATIVE
170
+ # path (so the digest survives moving or re-cloning the checkout) followed by its bytes, in sorted path
171
+ # order.
158
172
  #
159
173
  # The walk itself is not memoised — {.process_identity} is where a production caller gets the
160
174
  # once-per-process value, and this stays the computation so a spec can point it at another tree.
@@ -178,12 +192,12 @@ module Rigor
178
192
  raise Unavailable, "engine source under #{root} could not be read: #{e.message}"
179
193
  end
180
194
 
181
- # Sorted absolute paths of every engine `.rb` file, across the directories that exist.
195
+ # Sorted, de-duplicated absolute paths of every engine `.rb` file and {DATA_GLOBS} data file, across
196
+ # the directories that exist. `plugins/*/sig/**/*.rbs` overlaps nothing else but is de-duplicated anyway.
182
197
  def source_files(root)
183
- SOURCE_DIRECTORIES.flat_map do |relative|
184
- directory = File.join(root, relative)
185
- File.directory?(directory) ? Dir.glob(File.join(directory, "**", "*.rb")) : []
186
- end.sort
198
+ sources = SOURCE_DIRECTORIES.flat_map { |relative| Dir.glob(File.join(root, relative, "**", "*.rb")) }
199
+ data = DATA_GLOBS.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }
200
+ (sources + data).uniq.sort
187
201
  end
188
202
  end
189
203
  end