rigortype 0.4.5 → 0.4.6

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -12,6 +12,8 @@ require_relative "../cache/file_digest"
12
12
  require_relative "../cache/incremental_snapshot"
13
13
  require_relative "run_cache_key"
14
14
  require_relative "path_expansion"
15
+ require_relative "require_relative_closure"
16
+ require_relative "run_disclosure_credit"
15
17
  require_relative "../plugin"
16
18
  require_relative "../plugin/source_rbs_synthesis_reporter"
17
19
  require_relative "../rbs_extended/reporter"
@@ -75,7 +77,10 @@ module Rigor
75
77
  # this and serves reused files from it without re-stamping: a run-level row is regenerated
76
78
  # every run wherever it is positioned, and slicing the run's full stream by path cached the
77
79
  # file-positioned ones (`effect.annotations-unchecked`, `source-rbs-*`).
78
- :per_file_diagnostics
80
+ :per_file_diagnostics,
81
+ # PR #2286 review M1 — the ADR-16 index this run's pre-pass built; `IncrementalSession` digests it
82
+ # to tell when a class-body DSL edit moved what a served file's dispatch read.
83
+ :synthetic_method_index
79
84
 
80
85
  # Issues #796 / #794 — the two run-level rows a NARROWED run cannot re-derive from the files it
81
86
  # analyses: the `definition-build-failed` set (whose producer is the analysis's own demand, and #696
@@ -109,6 +114,13 @@ module Rigor
109
114
  pooled.empty? ? @analysis_reads : @analysis_reads.merge(pooled)
110
115
  end
111
116
 
117
+ # Issue #2020 — `{path => [run-disclosure record]}`, the run-scoped rows each analysed file's plugin hooks
118
+ # registered ({RunDisclosureCredit}), sequential and pooled alike; empty unless `record_dependencies:`.
119
+ def analysis_disclosures
120
+ pooled = @pool_coordinator.collected_analysis_disclosures
121
+ pooled.empty? ? @analysis_disclosures : @analysis_disclosures.merge(pooled)
122
+ end
123
+
112
124
  # ADR-103 — the run's propagated effect graph, or {Effects::EffectTable.empty} when collection did
113
125
  # not run. Reconciled from the sequential and pooled halves exactly as {#file_dependencies} is, then
114
126
  # closed by the post-pool fixpoint in {#assemble_run_diagnostics}.
@@ -294,10 +306,11 @@ module Rigor
294
306
  # worker continues to build its own Environment.
295
307
  # @param discovery_seed — issue #260 — opt-in cross-file discovery tables, keyed by
296
308
  # {Scope::DiscoveryIndex} slot name, seeded onto every per-file scope through
297
- # `project_scope_seed_tables`. The ONE deliberate exception to "a `prebuilt:` runner carries no
298
- # discovery tables": {Protection::DiagnosticOracle} threads the table set Tier 2's site filter already
299
- # judges anchors against, so a site admitted because a sibling-file class resolved is also a site the
300
- # oracle can kill at. nil (the default) leaves the prebuilt/LSP contract byte-identical.
309
+ # `project_scope_seed_tables`. A `prebuilt:` runner computes no discovery tables of its own, so this is
310
+ # how one learns the project: {Protection::DiagnosticOracle} threads the table set Tier 2's site filter
311
+ # already judges anchors against, and (issue #2147, ADR-124) the LSP's per-buffer publish threads
312
+ # `LanguageServer::ProjectContext#discovery_seed_for`, so a class another file declares is not an
313
+ # external ancestor there. nil (the default) seeds nothing.
301
314
  # @param no_tolerated_effects — ADR-103 WD1 / #385 — `rigor check --no-tolerated-effects`.
302
315
  # Judges effect envelopes as if `effects.tolerated:` were empty. A judgment-time switch only: the
303
316
  # run, its collection and its cache identity are unchanged.
@@ -309,7 +322,7 @@ module Rigor
309
322
  seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
310
323
  discovery_seed: nil, no_tolerated_effects: false,
311
324
  restored_run_level_rows: nil, served_per_file_diagnostics: nil, served_effect_collections: nil,
312
- on_file_indexed: nil)
325
+ served_run_disclosures: nil, on_file_indexed: nil)
313
326
  @configuration = configuration
314
327
  # ADR-113 WD2 — `rigor lens` reads a target's seeded index here: called with `(path, parse_result, scope,
315
328
  # index)` for every file this process indexes, after its rules ran and inside the run window, so the reader
@@ -327,6 +340,9 @@ module Rigor
327
340
  # effect graph this run closes (and the envelope pass judges) spans the whole project, not just the closure.
328
341
  # Never part of {#effect_collections_by_path}, which stays what THIS run collected. Empty on every other run.
329
342
  @served_effect_collections = served_effect_collections || {}
343
+ # Issue #2020 — the run-scoped plugin rows ({RunDisclosureCredit}) those unanalysed files registered when
344
+ # they were last analysed, emitted beside this run's own registrations. Empty on every other run.
345
+ @served_run_disclosures = served_run_disclosures || []
330
346
  @explain = explain
331
347
  @cache_store = enforce_read_only_cache(cache_store, buffer)
332
348
  @plugin_requirer = plugin_requirer
@@ -382,6 +398,7 @@ module Rigor
382
398
  # rows as they stood around the per-file loop; see {#incremental_slot_rows}. Recorded only by a
383
399
  # dependency-recording (incremental) run.
384
400
  @analysis_reads = {}
401
+ @analysis_disclosures = {}
385
402
  @pre_analysis_plugin_rows = nil
386
403
  @post_analysis_plugin_rows = nil
387
404
  # Memoised activation decision for the `call.self-undefined-method` rule (nil = not yet computed).
@@ -521,6 +538,16 @@ module Rigor
521
538
  end
522
539
  end
523
540
 
541
+ # ADR-45 WD2 (#1507) / #2020 — the per-run reset of what each file's analysis is credited with: its plugin
542
+ # reads, its run-scoped plugin registrations, and the boundary rows around the per-file loop.
543
+ def reset_analysis_attribution
544
+ @analysis_reads = {}
545
+ @analysis_disclosures = {}
546
+ @pre_analysis_plugin_rows = nil
547
+ @post_analysis_plugin_rows = nil
548
+ end
549
+ private :reset_analysis_attribution
550
+
524
551
  def run_analysis(paths)
525
552
  Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
526
553
  @configuration.fold_platform_specific_paths
@@ -548,9 +575,7 @@ module Rigor
548
575
  # #788 — the per-file reader is assigned only on the analysis (miss) path; reset it here so a run the
549
576
  # ADR-45 result cache serves does not answer with the previous run's rows.
550
577
  @per_file_diagnostics = [].freeze
551
- @analysis_reads = {}
552
- @pre_analysis_plugin_rows = nil
553
- @post_analysis_plugin_rows = nil
578
+ reset_analysis_attribution
554
579
  # ADR-84 WD2 — roll the return-memo bucket: a fresh frozen token per run makes every per-file scope
555
580
  # of THIS run share one memo bucket while entries from any earlier run in this process (stale after
556
581
  # an edit) become unreachable.
@@ -1414,7 +1439,7 @@ module Rigor
1414
1439
  end
1415
1440
 
1416
1441
  def build_run_dependency_descriptor(expansion, rbs_descriptor)
1417
- entries = analyzed_file_entries(expansion) + discovery_file_entries(expansion) +
1442
+ entries = analyzed_file_entries(expansion) + discovery_file_entries(expansion) + required_missing_entries +
1418
1443
  pre_eval_file_entries + template_unit_file_entries + rbs_descriptor.files +
1419
1444
  root_presence_entries(expansion)
1420
1445
  # #979 — the signature ROOTS' listings, one glob row each. `rbs_descriptor.files` covers only the
@@ -1509,7 +1534,8 @@ module Rigor
1509
1534
  IncrementalSlotRows.new(
1510
1535
  observed: plugin_boundary_rows,
1511
1536
  derived: Cache::Descriptor.new(
1512
- files: template_unit_file_entries + kind_entries(Array(roots) + @configuration.pre_eval),
1537
+ files: template_unit_file_entries + kind_entries(Array(roots) + @configuration.pre_eval) +
1538
+ required_missing_entries,
1513
1539
  globs: template_unit_glob_entries
1514
1540
  ),
1515
1541
  by_file: @analysis_reads.transform_values do |reads|
@@ -1568,7 +1594,8 @@ module Rigor
1568
1594
  BaselineRows.new(
1569
1595
  owned: Cache::Descriptor.new(
1570
1596
  files: own.files + kind_entries(signature_root_candidates) +
1571
- discovery_file_entries({ files: files }) + unanalysed_pre_eval_entries(files),
1597
+ discovery_file_entries({ files: files }) + required_missing_entries +
1598
+ unanalysed_pre_eval_entries(files),
1572
1599
  globs: (own.globs + discovery_glob_entries).uniq
1573
1600
  ),
1574
1601
  pinned: pinned
@@ -1808,20 +1835,19 @@ module Rigor
1808
1835
  end
1809
1836
 
1810
1837
  # Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
1811
- # Memoised on `@project_discovery_done` (reset at the start of `#run`). No-op under `@prebuilt` — the
1812
- # LSP path deliberately seeds an empty project scope from a snapshot that carries no discovery tables,
1813
- # so forcing a build there would change that contract. A caller that DOES want cross-file knowledge under
1814
- # `prebuilt:` supplies it explicitly through `discovery_seed:` (issue #260) rather than by re-walking the
1815
- # project here. Called eagerly from `#run` for the recording /
1816
- # subset (ADR-46) modes and lazily from `#assemble_run_diagnostics` on the analysis path, so a warm
1817
- # cache HIT (which never assembles) never pays the double parse.
1838
+ # Memoised on `@project_discovery_done` (reset at the start of `#run`). No-op under `@prebuilt`: the
1839
+ # snapshot carries no discovery tables, and a per-request re-walk of the project is what `prebuilt:` exists
1840
+ # to avoid. A caller that wants cross-file knowledge under `prebuilt:` supplies it through `discovery_seed:`
1841
+ # (issue #260), as the LSP publish does since issue #2147 (ADR-124). Called eagerly from `#run` for the
1842
+ # recording / subset (ADR-46) modes and lazily from `#assemble_run_diagnostics` on the analysis path, so a
1843
+ # warm cache HIT (which never assembles) never pays the double parse.
1818
1844
  def ensure_project_discovery(expansion)
1819
1845
  return if @prebuilt
1820
1846
  return if @project_discovery_done
1821
1847
 
1822
1848
  @project_discovery_done = true
1823
1849
  # Issue #684 — the cross-file index spans the PROJECT even when the analysis targets a file list.
1824
- expansion = @project_discovery_expansion || once_each(expansion)
1850
+ expansion = buffer_spelled(@project_discovery_expansion || once_each(expansion))
1825
1851
  if @collect_seed_bundles
1826
1852
  # ADR-85 WD2 — rebuild discovery from the prior run's bundles (re-walking only changed files) and
1827
1853
  # capture the refreshed bundle set for the session to persist.
@@ -1853,7 +1879,13 @@ module Rigor
1853
1879
  # on what else was on the command line.
1854
1880
  #
1855
1881
  # Excluded, each because its expansion is already the one it means: the LSP `prebuilt:` path (which
1856
- # seeds discovery from a snapshot), editor `buffer:` mode, and `run_source`'s in-memory single file.
1882
+ # seeds discovery from a snapshot, ADR-124) and `run_source`'s in-memory single file.
1883
+ #
1884
+ # Issue #2165 — editor `buffer:` mode (`rigor check --tmp-file T --instead-of F F`) widens like a plain run.
1885
+ # It was excluded on the reading that its expansion is already the one it means, but an editor passes the
1886
+ # buffer's own path, so discovery saw that file alone: every class another file declares read as an external
1887
+ # ancestor, and the run reported what `rigor check F` does not. The discovery pre-pass reads through the
1888
+ # binding, so the buffer's file is walked from the buffer's bytes.
1857
1889
  #
1858
1890
  # Issue #1583 — ADR-46's `analyze_only` subset mode widens like a full run. It was excluded on the
1859
1891
  # reading that it "already passes the full expansion", which holds only when the incremental session
@@ -1867,15 +1899,51 @@ module Rigor
1867
1899
  # the pre-pass then saw two writers for every constant a file declares and withheld its publication (#644),
1868
1900
  # and every file was a discovered-not-analysed dependency of the run's cache entry.
1869
1901
  #
1902
+ # Issue #2021 — an analysed file outside the configured paths has no project there, so the widened set also
1903
+ # holds what it loads with a literal `require_relative` ({#required_outside_paths}). A plugin file checked on
1904
+ # its own otherwise missed its sibling's nested `Effects` and resolved the project's `Rigor::Effects`.
1905
+ #
1870
1906
  # The widening decision itself compares the paths as spelled. `exclude:` patterns apply to both spellings
1871
1907
  # alike (#1576), so the configured absolute `lib` and the argument `lib` exclude the same files.
1872
1908
  def project_discovery_expansion(paths, expansion)
1873
1909
  return nil unless widen_discovery_to_project?(paths)
1874
1910
 
1875
1911
  analysed = expansion.fetch(:files)
1876
- files = discovery_files(expand_paths(@configuration.paths | paths).fetch(:files), analysed)
1912
+ project = expand_paths(@configuration.paths | paths).fetch(:files)
1913
+ walked = required_outside_paths(analysed)
1914
+ files = discovery_files(project + walked.found, analysed)
1877
1915
  # Overlapping arguments (`lib lib/user.rb`) name an analysed file twice; the widened set names it once.
1878
- files.size > analysed.uniq { |path| File.absolute_path(path) }.size ? { files: files, errors: [] } : nil
1916
+ return nil unless files.size > analysed.uniq { |path| File.absolute_path(path) }.size || walked.missing.any?
1917
+
1918
+ { files: files, errors: [], uncertain: walked.found.to_set.freeze, missing: walked.missing }
1919
+ end
1920
+
1921
+ # Issue #2021 — what the analysed files outside the configured `paths:` load with a literal `require_relative`,
1922
+ # through targets that are themselves outside them ({RequireRelativeClosure}). A file under a configured path
1923
+ # is discovered already, so the walk adds nothing for a run whose files all lie inside them. The comparison is
1924
+ # by real path, so a symlinked spelling of a configured file does not join discovery a second time (two
1925
+ # writers would withhold its constants, #644). `exclude:` drops a target from discovery as it drops a
1926
+ # configured file; the walk still follows what an excluded target requires.
1927
+ #
1928
+ # The found files are discovered as `uncertain` ({Analysis::Runner::ProjectPrePasses#uncertain_paths}): the walk
1929
+ # sees one entry point's load graph, and a file it does not reach may redefine what they define.
1930
+ def required_outside_paths(analysed)
1931
+ roots = @configuration.paths.map { |root| RequireRelativeClosure.real_path(root) }
1932
+ covered = ->(real) { roots.any? { |root| real == root || real.start_with?(File.join(root, "")) } }
1933
+ seeds = analysed.reject { |path| covered.call(RequireRelativeClosure.real_path(path)) }
1934
+ return RequireRelativeClosure::Result.new(found: [], missing: []) if seeds.empty?
1935
+
1936
+ read = ->(path) { File.read(@buffer ? @buffer.resolve(path) : path) }
1937
+ walked = RequireRelativeClosure.closure(seeds, covered: covered, read: read)
1938
+ walked.with(found: PathExpansion.reject_excluded(walked.found, @configuration.exclude_patterns,
1939
+ base: @configuration.exclude_base))
1940
+ end
1941
+
1942
+ # Issue #2021 — a kind row per `require_relative` target the walk named and found absent: a file appearing
1943
+ # there joins the discovery, so it moves the answer of the run-result cache and the `--incremental` slot.
1944
+ def required_missing_entries
1945
+ missing = @project_discovery_expansion&.fetch(:missing, nil)
1946
+ missing.nil? || missing.empty? ? [] : kind_entries(missing)
1879
1947
  end
1880
1948
 
1881
1949
  # The expansion with each file once, first spelling kept: overlapping arguments (`lib ./lib`) that do not
@@ -1887,6 +1955,23 @@ module Rigor
1887
1955
  unique.size == files.size ? expansion : expansion.merge(files: unique)
1888
1956
  end
1889
1957
 
1958
+ # Issue #2165 — the discovery set with the buffer's file under the binding's own spelling, once. The binding
1959
+ # substitutes bytes only for a path spelled exactly as its logical path, while the file can reach discovery as
1960
+ # the argument's spelling, the configured one, or through a symlink; each of those read it from disk, and a
1961
+ # second spelling made it a second declarer. Every member whose real path is the buffer's file is replaced by
1962
+ # the logical path, and the duplicates that leaves are dropped (`IncrementalSession#rebind_buffer` is option
1963
+ # B's counterpart).
1964
+ def buffer_spelled(expansion)
1965
+ return expansion if @buffer.nil?
1966
+
1967
+ target = Analysis::BufferBinding.real_path(@buffer.logical_path)
1968
+ files = expansion.fetch(:files)
1969
+ spelled = files.map do |path|
1970
+ Analysis::BufferBinding.real_path(path) == target ? @buffer.logical_path : path
1971
+ end.uniq
1972
+ spelled == files ? expansion : expansion.merge(files: spelled)
1973
+ end
1974
+
1890
1975
  # Each file once, in project order, under the spelling the analysis uses when it is analysed.
1891
1976
  def discovery_files(project_files, analysed_files)
1892
1977
  spelling = analysed_files.to_h { |path| [File.absolute_path(path), path] }
@@ -1898,7 +1983,7 @@ module Rigor
1898
1983
  end
1899
1984
 
1900
1985
  def widen_discovery_to_project?(paths)
1901
- @prebuilt.nil? && @buffer.nil? && @in_memory_sources.nil? && paths != @configuration.paths
1986
+ @prebuilt.nil? && @in_memory_sources.nil? && paths != @configuration.paths
1902
1987
  end
1903
1988
 
1904
1989
  # ADR-46 — the dependency-recording and subset-analysis modes read the discovery tables OUTSIDE the
@@ -1912,7 +1997,8 @@ module Rigor
1912
1997
 
1913
1998
  private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result,
1914
1999
  :apply_discovery_result, :ensure_project_discovery, :force_eager_discovery?,
1915
- :project_discovery_expansion, :discovery_files, :once_each, :widen_discovery_to_project?
2000
+ :project_discovery_expansion, :discovery_files, :once_each, :widen_discovery_to_project?,
2001
+ :required_outside_paths, :required_missing_entries
1916
2002
 
1917
2003
  # Ruby versions probed (ascending) to discover the lowest one this Prism build accepts for
1918
2004
  # `version:`. Prism exposes no version list, so the floor is found empirically — only when a
@@ -2102,6 +2188,7 @@ module Rigor
2102
2188
  hkt_scan_failure_snapshot: -> { @snapshots.hkt_scan_failure },
2103
2189
  conformance_results_snapshot: -> { @snapshots.conformance_results },
2104
2190
  pooled_run_disclosures: -> { @pool_coordinator.collected_run_disclosures },
2191
+ served_run_disclosures: -> { @served_run_disclosures },
2105
2192
  pooled_trust_refusals: -> { @pool_coordinator.collected_trust_refusals }
2106
2193
  )
2107
2194
  end
@@ -2249,10 +2336,8 @@ module Rigor
2249
2336
  # client editing a brand-new file.
2250
2337
  elsif accept_as_ruby_file?(path)
2251
2338
  files << path
2252
- elsif File.exist?(path)
2253
- bad << [path, "not a Ruby file (expected `.rb` or a directory)"]
2254
2339
  else
2255
- bad << [path, "no such file or directory"]
2340
+ bad << [path, PathExpansion.unusable_message(path)]
2256
2341
  end
2257
2342
  end
2258
2343
  { files: files, roots: Array(paths).map(&:to_s), errors: path_expansion_errors(bad, any_files: files.any?) }
@@ -2262,27 +2347,24 @@ module Rigor
2262
2347
  # app lib` with no `lib/` (the 20260620 field trial's strap case) analyses `app` instead of aborting
2263
2348
  # on exit 1. A bad path that leaves NOTHING to analyse stays an error so a lone typo (`rigor check
2264
2349
  # typo.rb`) is not silently masked (the regression the path-error check was added to catch in the
2265
- # first place).
2350
+ # first place). The wording and grading live in {PathExpansion.diagnostics}, shared with `sig-gen` (#1991).
2266
2351
  def path_expansion_errors(bad, any_files:)
2267
- severity = any_files ? :warning : :error
2268
- suffix = any_files ? " (skipped)" : ""
2269
- bad.map { |path, message| path_error(path, "#{message}#{suffix}", severity: severity) }
2352
+ PathExpansion.diagnostics(bad, any_files: any_files)
2270
2353
  end
2271
2354
 
2355
+ # #2191 — the buffer's logical path is accepted under any spelling that names the same file (relative, absolute,
2356
+ # or through a symlinked root), since the discovery set respells it to the logical path (`buffer_spelled`).
2272
2357
  def accept_as_ruby_file?(path)
2273
2358
  (File.file?(path) && path.end_with?(".rb")) ||
2274
- (@buffer && path == @buffer.logical_path) ||
2359
+ buffer_path?(path) ||
2275
2360
  @in_memory_sources&.key?(path)
2276
2361
  end
2277
2362
 
2278
- def path_error(path, message, severity: :error)
2279
- Diagnostic.new(
2280
- path: path,
2281
- line: 1,
2282
- column: 1,
2283
- message: message,
2284
- severity: severity
2285
- )
2363
+ def buffer_path?(path)
2364
+ return false if @buffer.nil?
2365
+
2366
+ path == @buffer.logical_path ||
2367
+ Analysis::BufferBinding.real_path(path) == Analysis::BufferBinding.real_path(@buffer.logical_path)
2286
2368
  end
2287
2369
 
2288
2370
  # Reads + parses the source at `path`. Under editor mode (`@buffer` set) reads bytes from
@@ -2481,12 +2563,31 @@ module Rigor
2481
2563
  # widener erases it to the class, and listing a file under `pre_eval:` must never LOSE precision. The
2482
2564
  # per-file table still wins over both (`ScopeIndexer.index`'s merge) — same-file stays the most
2483
2565
  # specific authority.
2566
+ #
2567
+ # ADR-124 — a `prebuilt:` runner computes no literal table of its own; a `discovery_seed:` (the editor
2568
+ # publish, ADR-124) brings the project's, which then plays the same part.
2484
2569
  def merged_seed_constants
2485
2570
  pre_eval = publishable_pre_eval_constants
2486
- return @project_constant_values if pre_eval.empty?
2487
- return pre_eval if @project_constant_values.empty?
2571
+ literal = literal_seed_constants
2572
+ return literal if pre_eval.empty?
2573
+ return pre_eval if literal.empty?
2574
+
2575
+ pre_eval.merge(literal).freeze
2576
+ end
2577
+
2578
+ # The project's literal constant table: this run's, or the one a `discovery_seed:` carries (ADR-124).
2579
+ def literal_seed_constants
2580
+ return @project_constant_values unless @project_constant_values.empty? && @discovery_seed
2581
+
2582
+ @discovery_seed.fetch(:in_source_constants, @project_constant_values)
2583
+ end
2584
+
2585
+ # The per-name writer paths the #663 retraction reads: this run's census, or the one a `discovery_seed:` carries
2586
+ # (ADR-124).
2587
+ def constant_writer_sources
2588
+ return @project_constant_sources unless @project_constant_sources.empty? && @discovery_seed
2488
2589
 
2489
- pre_eval.merge(@project_constant_values).freeze
2590
+ @discovery_seed.fetch(:constant_sources, @project_constant_sources)
2490
2591
  end
2491
2592
 
2492
2593
  # Issue #663 — the `pre_eval:` publication with every name an UNLISTED file also assigns retracted.
@@ -2515,8 +2616,9 @@ module Rigor
2515
2616
  # name of its own for the wildcard's paths to have been folded into.
2516
2617
  def unlisted_constant_writer?(name, listed)
2517
2618
  segment = name.split("::").last
2518
- writers = @project_constant_sources[name]
2519
- wildcard = @project_constant_sources["#{Inference::ScopeIndexer::DYNAMIC_TARGET_PREFIX}#{segment}"]
2619
+ sources = constant_writer_sources
2620
+ writers = sources[name]
2621
+ wildcard = sources["#{Inference::ScopeIndexer::DYNAMIC_TARGET_PREFIX}#{segment}"]
2520
2622
  [writers, wildcard].any? do |paths|
2521
2623
  paths&.any? { |path| !listed.include?(File.absolute_path(path)) }
2522
2624
  end
@@ -2560,13 +2662,18 @@ module Rigor
2560
2662
  @pre_analysis_plugin_rows ||= plugin_boundary_rows
2561
2663
  reads = []
2562
2664
  diagnostics = nil
2563
- record = Plugin::IoBoundary.attributing(reads) do
2564
- DependencyRecorder.record_for(path) do
2565
- diagnostics = analyze_with_effects(path, environment)
2665
+ record = nil
2666
+ # Issue #2020 — and every run-scoped row its analysis registered, with the registering plugins' reads.
2667
+ disclosures = RunDisclosureCredit.collect do
2668
+ record = Plugin::IoBoundary.attributing(reads) do
2669
+ DependencyRecorder.record_for(path) do
2670
+ diagnostics = analyze_with_effects(path, environment)
2671
+ end
2566
2672
  end
2567
2673
  end
2568
2674
  @file_dependencies[path] = record
2569
2675
  @analysis_reads[path] = reads unless reads.empty?
2676
+ @analysis_disclosures[path] = disclosures unless disclosures.empty?
2570
2677
  diagnostics
2571
2678
  end
2572
2679
 
@@ -20,6 +20,7 @@ require_relative "../inference/method_dispatcher/file_folding"
20
20
  require_relative "check_rules"
21
21
  require_relative "crash_signature"
22
22
  require_relative "dependency_recorder"
23
+ require_relative "run_disclosure_credit"
23
24
  require_relative "dependency_source_inference"
24
25
  require_relative "diagnostic"
25
26
  require_relative "erb_template_detector"
@@ -171,12 +172,17 @@ module Rigor
171
172
 
172
173
  diagnostics = nil
173
174
  reads = []
174
- record = Plugin::IoBoundary.attributing(reads) do
175
- DependencyRecorder.record_for(path) { diagnostics = analyze_with_effects(path) }
175
+ record = nil
176
+ # Issue #2020 — and the run-scoped rows it registered, as `Runner#analyze_file` keeps them.
177
+ disclosures = RunDisclosureCredit.collect do
178
+ record = Plugin::IoBoundary.attributing(reads) do
179
+ DependencyRecorder.record_for(path) { diagnostics = analyze_with_effects(path) }
180
+ end
176
181
  end
177
182
  @file_dependencies[path] = record
178
183
  # Issue #1553 — the plugin reads this file's analysis made, as `Runner#analyze_file` keeps them.
179
184
  (@analysis_reads ||= {})[path] = reads unless reads.empty?
185
+ (@analysis_disclosures ||= {})[path] = disclosures unless disclosures.empty?
180
186
  diagnostics
181
187
  end
182
188
 
@@ -320,6 +326,12 @@ module Rigor
320
326
  @analysis_reads || {}
321
327
  end
322
328
 
329
+ # Issue #2020 — the run-scoped rows each file's analysis registered, `{path => [record]}`; see
330
+ # {RunDisclosureCredit}. Marshal-clean like {#drain_run_disclosures}.
331
+ def drain_analysis_disclosures
332
+ @analysis_disclosures || {}
333
+ end
334
+
323
335
  # ADR-103 WD12 — the per-file {Effects::FileCollection}s this session collected (empty unless the
324
336
  # configuration carries an `effects:` block). Marshal-clean by construction — frozen Hashes of
325
337
  # Strings, `LabelSet`s of frozen Arrays, and `Data` edges — so the fork pool ships them back to the
@@ -182,7 +182,20 @@ module Rigor
182
182
  # 41: issue #1583 — the payload gains `discovery_digests`, the packed digest of each file a run over path
183
183
  # arguments discovered without analysing, which a recheck compares to re-baseline when one moved. A pre-41
184
184
  # blob carries none; the gate rejects it and the next run is cold.
185
- SCHEMA = 41
185
+ # 42: issue #2184 — a `deferred_ranges` row for a `def` inside a block now carries the method's name, and a
186
+ # literal-named `define_method` / `define_singleton_method` call adds a row. A pre-42 bundle carries neither,
187
+ # so a warm run would report a top-level block call that a cold run declines.
188
+ # 43: issue #2020 — the payload gains `plugin_disclosures`, the run-scoped plugin rows each analysed file's
189
+ # last analysis registered, which a recheck replays for the files it serves. A pre-43 blob carries none, so
190
+ # its rechecks would drop every such row; the gate rejects it and the next run is cold.
191
+ # 44: PR #2286 review M1 — the payload gains `synthetic_surface_digest`, the ADR-16 synthetic-method index's
192
+ # {Rigor::Inference::SyntheticMethodIndex#surface_digest} at the run the cache was built under, which a recheck
193
+ # compares to re-baseline when a class-body DSL edit moved it. A pre-44 blob carries none; the gate rejects it
194
+ # and the next run is cold.
195
+ # 45: issue #1154 — each seed bundle's `global_write_census` also records the ivar writers the declared-parameter
196
+ # credit reads (`[:ivar_set, name]`, `[:ivar_set_class, class]`, `[:own_write, class, name, path]`), which a
197
+ # pre-45 bundle carries none of, so a warm run would credit an ivar write a cold run declines.
198
+ SCHEMA = 45
186
199
 
187
200
  # The persisted per-file state.
188
201
  # `cache` maps an analyzed file to its diagnostics.
@@ -244,16 +257,24 @@ module Rigor
244
257
  # `effect_collections` (nil when collection was off). A plugin stamps its attributions and edges into a file's
245
258
  # collection, and `effects_identity` is plugin-blind (it is compared before any plugin loads), so the session
246
259
  # compares this one after the recheck has loaded them.
260
+ # Issue #2020 — `plugin_disclosures` is {Analysis::RunDisclosureLedger#to_h}: `files`, each analysed file's
261
+ # run-scoped plugin rows ({Plugin::Base#disclose_once} / {Plugin::Base#emit_once}) from its last analysis, which a
262
+ # recheck replays for the files it serves, and `reads`, the boundary rows per `[plugin id, key]` that make one
263
+ # stale.
247
264
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
248
265
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
249
266
  :missing, :class_decls, :constant_decls, :seed_bundles, :plugin_fact_digest,
250
267
  :return_summaries, :param_table,
251
268
  :effect_collections, :effects_identity, :run_level_rows,
252
- :effect_plugin_digest, :plugin_reads, :discovery_digests) do
269
+ :effect_plugin_digest, :plugin_reads, :discovery_digests, :plugin_disclosures,
270
+ :synthetic_surface_digest) do
253
271
  # `effect_plugin_digest` defaults to nil so a payload built without it (an older caller, a spec fixture)
254
272
  # reads as "unknown", which the session treats as moved. `plugin_reads` defaults to no reads, and
255
- # `discovery_digests` to no discovery-only files.
256
- def initialize(effect_plugin_digest: nil, plugin_reads: {}, discovery_digests: {}, **rest)
273
+ # `discovery_digests` to no discovery-only files, and `plugin_disclosures` (#2020) to no replayed rows.
274
+ # `synthetic_surface_digest` defaults to nil, the empty index's digest: a run that builds a non-empty one
275
+ # reads it as moved.
276
+ def initialize(effect_plugin_digest: nil, plugin_reads: {}, discovery_digests: {}, plugin_disclosures: {},
277
+ synthetic_surface_digest: nil, **rest)
257
278
  super
258
279
  end
259
280
  end
@@ -465,7 +486,9 @@ module Rigor
465
486
  run_level_rows: data[:run_level_rows],
466
487
  effect_plugin_digest: data[:effect_plugin_digest],
467
488
  plugin_reads: data[:plugin_reads] || {},
468
- discovery_digests: data.fetch(:discovery_digests, {})
489
+ plugin_disclosures: data.fetch(:plugin_disclosures, {}),
490
+ discovery_digests: data.fetch(:discovery_digests, {}),
491
+ synthetic_surface_digest: data[:synthetic_surface_digest]
469
492
  )
470
493
  end
471
494
  private :payload_from
@@ -492,8 +515,8 @@ module Rigor
492
515
  effects_identity: payload.effects_identity,
493
516
  run_level_rows: payload.run_level_rows,
494
517
  effect_plugin_digest: payload.effect_plugin_digest,
495
- plugin_reads: payload.plugin_reads,
496
- discovery_digests: payload.discovery_digests
518
+ plugin_reads: payload.plugin_reads, plugin_disclosures: payload.plugin_disclosures,
519
+ discovery_digests: payload.discovery_digests, synthetic_surface_digest: payload.synthetic_surface_digest
497
520
  )
498
521
  # #1507 — every `--incremental` run that analysed anything rewrites this whole blob, so its deflate is
499
522
  # on the edit path; see {Store::DEFLATE_LEVEL}.
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require "optionparser"
6
+ require "tempfile"
6
7
 
7
8
  require_relative "../configuration"
8
9
  require_relative "../config_audit"
@@ -11,6 +12,7 @@ require_relative "../analysis/rule_catalog"
11
12
  # The baseline filter runs on EVERY exit path — including the ADR-87 WD4 cache-HIT fast path, which skips
12
13
  # `load_check_dependencies` — so it must be a load-time require. It pulls only YAML, never the engine.
13
14
  require_relative "../analysis/baseline"
15
+ require_relative "../analysis/edit_replay"
14
16
  require_relative "command"
15
17
  require_relative "options"
16
18
  require_relative "diagnostic_formats"
@@ -272,8 +274,83 @@ module Rigor
272
274
  incremental = session.reanalyze_subset(subset).map(&:to_h)
273
275
  full = verify_full_diagnostics(configuration, paths, collect_stats: options.fetch(:stats)).map(&:to_h)
274
276
 
275
- report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size,
276
- format: options.fetch(:format))
277
+ status = report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size,
278
+ format: options.fetch(:format))
279
+ return status unless status.zero? && options.fetch(:verify_incremental_edits).positive?
280
+
281
+ verify_edit_replays(session, configuration, paths, options)
282
+ end
283
+
284
+ # Issue #1967 — the partition check above re-analyses an unchanged tree, so every served row is still right and a
285
+ # missing invalidation edge goes unseen. Here each of `--verify-incremental-edits` sampled files is edited through
286
+ # an editor buffer (never on disk) by each of {Analysis::EditReplay.edits} — emptied, then reduced to its
287
+ # `class` / `module` skeleton — and the warm recheck of the baseline ({IncrementalSession#replay_edit}) must equal
288
+ # a full analysis of the edited tree: a dependent the recheck failed to re-analyse is served from the baseline and
289
+ # differs. A run in which every replay declined compared nothing, so it fails rather than report OK.
290
+ def verify_edit_replays(session, configuration, paths, options)
291
+ sample = edit_replay_sample(session.analyzed_files, options.fetch(:verify_incremental_edits))
292
+ replayed = 0
293
+ declined = 0
294
+ sample.each do |path|
295
+ Analysis::EditReplay.edits(File.read(path)).each do |edit|
296
+ incremental, full = replay_edit(session, configuration, paths, path, edit)
297
+ next declined += 1 if incremental.nil?
298
+
299
+ replayed += 1
300
+ next if incremental == full
301
+
302
+ return report_edit_replay_mismatch(path, edit, incremental, full)
303
+ end
304
+ end
305
+ report_edit_replays(replayed, declined, options.fetch(:format))
306
+ end
307
+
308
+ def report_edit_replay_mismatch(path, edit, incremental, full)
309
+ @err.puts("rigor: --verify-incremental FAILED — the recheck after #{edit.label} #{path} " \
310
+ "differs from a full run.")
311
+ return report_verify_order_mismatch(incremental, full) if incremental.tally == full.tally
312
+
313
+ report_verify_set_mismatch(incremental, full, header: false)
314
+ end
315
+
316
+ # `count` files spread evenly over the analysed list, so a small count still reaches both ends of it.
317
+ def edit_replay_sample(analyzed, count)
318
+ return analyzed if count >= analyzed.size
319
+
320
+ Array.new(count) { |index| analyzed[index * analyzed.size / count] }
321
+ end
322
+
323
+ # The warm recheck's rows and a full run's, both with `path` replaced by `edit`; `[nil, nil]` when the recheck
324
+ # declined.
325
+ def replay_edit(session, configuration, paths, path, edit)
326
+ Tempfile.create(["rigor-verify-edit-", ".rb"]) do |file|
327
+ file.write(edit.source)
328
+ file.close
329
+ buffer = Analysis::BufferBinding.new(logical_path: path, physical_path: file.path)
330
+ recheck = session.replay_edit(buffer)
331
+ return [nil, nil] if recheck.nil?
332
+
333
+ # `analyze_only:` with the buffer selects editor mode option B, a whole-project run reading the buffer at
334
+ # its logical path; the buffer alone would analyse that one file.
335
+ runner = Analysis::Runner.new(configuration: configuration, cache_store: nil, collect_stats: false,
336
+ buffer: buffer, analyze_only: session.analyzed_files.to_set)
337
+ full = paths ? runner.run(paths) : runner.run
338
+ [recheck.diagnostics.map(&:to_h), full.diagnostics.map(&:to_h)]
339
+ end
340
+ end
341
+
342
+ def report_edit_replays(replayed, declined, format)
343
+ if replayed.zero? && declined.positive?
344
+ @err.puts("rigor: --verify-incremental FAILED — every edit replay (#{declined}) declined, " \
345
+ "since the recheck would have been a full analysis, so no edit was compared.")
346
+ return 1
347
+ end
348
+
349
+ line = "rigor: --verify-incremental OK — #{replayed} edits replayed (files emptied and reduced to their " \
350
+ "class/module skeleton), every recheck matches full"
351
+ line = "#{line}; #{declined} declined (the recheck would have been a full analysis)" if declined.positive?
352
+ side_output(format).puts(line)
353
+ 0
277
354
  end
278
355
 
279
356
  # ADR-46 — cross-process incremental analysis (`--incremental`). Derives the global fingerprint cheaply (no RBS
@@ -490,10 +567,10 @@ module Rigor
490
567
  end
491
568
  end
492
569
 
493
- def report_verify_set_mismatch(incremental, full)
570
+ def report_verify_set_mismatch(incremental, full, header: true)
494
571
  only_incremental = multiset_difference(incremental, full)
495
572
  only_full = multiset_difference(full, incremental)
496
- @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.")
573
+ @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.") if header
497
574
  @err.puts(" incremental-only: #{only_incremental.size}, full-only: #{only_full.size}")
498
575
  (only_incremental + only_full).first(10).each do |hash|
499
576
  @err.puts(" #{diagnostic_location(hash)}")
@@ -643,6 +720,9 @@ module Rigor
643
720
  # the rest from the per-file cache, and asserts the merged diagnostics are byte-identical to a full
644
721
  # `--no-cache` run. Exits non-zero on any mismatch. Off by default.
645
722
  verify_incremental: false,
723
+ # Issue #1967 — the number of files `--verify-incremental` replays an edit on (each emptied through an editor
724
+ # buffer) after the partition check. 0 replays none.
725
+ verify_incremental_edits: 0,
646
726
  # ADR-46 — cross-process incremental analysis. With a disk snapshot of the prior run's per-file diagnostics +
647
727
  # dependency graph, re-analyzes only the changed closure and serves the rest from the snapshot. Off by
648
728
  # default.
@@ -720,6 +800,15 @@ module Rigor
720
800
  "assert incremental analysis matches a full run, then exit") do
721
801
  options[:verify_incremental] = true
722
802
  end
803
+ opts.on("--verify-incremental-edits=N", Integer,
804
+ "with --verify-incremental, also replay 2 edits (empty, class/module skeleton) on N files") do |value|
805
+ if value.negative?
806
+ raise OptionParser::InvalidArgument, "--verify-incremental-edits=#{value} (must be 0 or more)"
807
+ end
808
+
809
+ options[:verify_incremental] = true
810
+ options[:verify_incremental_edits] = value
811
+ end
723
812
  opts.on("--incremental",
724
813
  "re-analyze only files changed since the last run (cross-process cache)") do
725
814
  options[:incremental] = true