rigortype 0.4.4 → 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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -5,6 +5,7 @@ require_relative "incremental"
5
5
  require_relative "incremental_run_slot"
6
6
  require_relative "incremental_run_slot/write_guard"
7
7
  require_relative "plugin_fact_fingerprint"
8
+ require_relative "run_disclosure_ledger"
8
9
  require_relative "source_rbs_gate"
9
10
  require_relative "../cache/file_digest"
10
11
  require_relative "../cache/incremental_snapshot"
@@ -40,15 +41,19 @@ module Rigor
40
41
  # unconditional snapshot rewrite.
41
42
  # `rebaselined` (#1898) is true when the recheck found its served effect collections stale and answered with a
42
43
  # full baseline instead; see {#rebaseline_recheck}.
43
- Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates, :rebaselined) do
44
- def initialize(rebaselined: false, **rest)
44
+ # `stale_readers` (#1553) are the files the recheck re-analysed because a plugin read one of them made went
45
+ # stale ({#plugin_read_affected}).
46
+ Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates, :rebaselined,
47
+ :stale_readers) do
48
+ def initialize(rebaselined: false, stale_readers: Set.new, **rest)
45
49
  super
46
50
  end
47
51
 
48
- # A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
49
- # snapshot that was restored, so persisting it again would only rewrite identical bytes.
52
+ # A recheck that changed, added, and removed nothing, and found no stale plugin read — the session state is
53
+ # byte-equivalent to the snapshot that was restored, so persisting it again would only rewrite identical
54
+ # bytes. A stale reader's refreshed rows differ (#1553): unsaved, every later run would re-analyse it.
50
55
  def no_change?
51
- changed.empty? && added.empty? && removed.empty?
56
+ changed.empty? && added.empty? && removed.empty? && stale_readers.empty?
52
57
  end
53
58
  end
54
59
 
@@ -99,6 +104,9 @@ module Rigor
99
104
  @cache = {} # analyzed path => [Diagnostic]
100
105
  @sources = {} # analyzed path => Set<source path it read from>
101
106
  @digests = {} # analyzed path => ADR-87 packed stat-digest entry at last analysis
107
+ # Issue #1583 — discovered-not-analysed path => the same packed entry, for a run over path arguments that
108
+ # #684 widened to the configured `paths:`. Empty on every run over the configured paths.
109
+ @discovery_digests = {}
102
110
  @analyzed = [] # the project files analyzed last round
103
111
  @dependents = {} # inverted @sources (file-level)
104
112
  # ADR-46 slice 4 — symbol-granularity tracking.
@@ -128,6 +136,13 @@ module Rigor
128
136
  # and a missing invalidation edge is impossible by construction — the reason this is a table diff
129
137
  # and not the caller→callee edge recording #204 first sketched.
130
138
  @param_table = {}
139
+ # Issue #1553 — analysed path => the plugin reads ({Plugin::IoBoundary.attributing}) its last analysis made:
140
+ # the frozen `Cache::Descriptor::FileEntry` / `GlobEntry` rows, as each read observed its input. A recheck
141
+ # re-analyses every file one of whose rows no longer validates ({#plugin_read_affected}).
142
+ @plugin_reads = {}
143
+ # Issue #2020 — the run-scoped plugin rows each analysed file registered, and the reads that make one stale.
144
+ @plugin_disclosures = RunDisclosureLedger.new
145
+ @stale_disclosures = Set.new
131
146
  reset_carried_state
132
147
  # ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
133
148
  # `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
@@ -151,6 +166,49 @@ module Rigor
151
166
  current_files
152
167
  end
153
168
 
169
+ # Issue #1583 (and #1560) — whether a file the run discovers but does not analyse was edited, added or
170
+ # removed since the snapshot. Such a file feeds the cross-file index the analysed files resolve against
171
+ # (a constant, a method, an ancestor), but no recorded edge or digest of the analysed set sees it move.
172
+ # A move re-baselines the session: discovery-only files are not tracked per symbol, so the closure
173
+ # cannot be narrowed, and the full analysis is over the argument set alone. A session over the
174
+ # configured `paths:` discovers nothing beyond what it analyses and pays nothing here.
175
+ def discovery_moved?
176
+ current = current_discovery_only_files
177
+ return true unless current.size == @discovery_digests.size
178
+
179
+ current.any? do |path|
180
+ entry = @discovery_digests[path]
181
+ entry.nil? || !Cache::FileDigest.stat_fresh?(path, entry)
182
+ end
183
+ rescue StandardError
184
+ true
185
+ end
186
+
187
+ # A baseline's freshness entries: each analysed file's, and (issue #1583) each discovered-not-analysed file's.
188
+ def record_baseline_digests
189
+ @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
190
+ @discovery_digests = current_discovery_only_files.to_h { |path| [path, pack_digest(path)] }
191
+ forget_unbound_digests
192
+ end
193
+
194
+ def restore_digests(payload)
195
+ @digests = payload.digests
196
+ @discovery_digests = payload.discovery_digests # Issue #1583 — `{}` in a payload built without it.
197
+ end
198
+
199
+ # Issue #1583 — the discovered-not-analysed files of a run over this session's paths
200
+ # ({Runner#discovery_only_file_set}). Asked of a runner WITHOUT the editor buffer: a buffered run does not
201
+ # widen, so a buffered session (editor mode option B, #146) would read an empty set against the snapshot's
202
+ # and re-baseline on every keystroke. Globbing without it keeps the comparison the one the snapshot's
203
+ # writer made, so a sibling edit still declines option B and an unchanged project still serves it.
204
+ def current_discovery_only_files
205
+ return [] if @paths.nil?
206
+
207
+ Runner.new(configuration: @configuration, cache_store: nil, environment: @environment)
208
+ .discovery_only_file_set(@paths)
209
+ end
210
+ private :discovery_moved?, :record_baseline_digests, :restore_digests, :current_discovery_only_files
211
+
154
212
  # The per-file discovery bundles of the last run, keyed by path (ADR-85 WD2).
155
213
  attr_reader :seed_bundles
156
214
 
@@ -215,9 +273,11 @@ module Rigor
215
273
  @effect_collections = runner.effect_collections_by_path
216
274
  @effects_identity = current_effects_identity
217
275
  @effect_plugin_digest = effect_plugin_digest_of(runner)
276
+ @synthetic_surface_digest = synthetic_surface_digest_of(runner)
218
277
  @cache = per_file(runner.per_file_diagnostics)
219
- @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
220
- forget_unbound_digests
278
+ @plugin_reads = plugin_reads_of(runner, @analyzed)
279
+ @plugin_disclosures = RunDisclosureLedger.from(runner.analysis_disclosures.slice(*@analyzed))
280
+ record_baseline_digests
221
281
  @run_level_rows = runner.run_level_rows
222
282
  diagnostics
223
283
  end
@@ -231,6 +291,8 @@ module Rigor
231
291
  added = current - previous
232
292
  removed = previous - current
233
293
  changed = changed_paths(current & previous)
294
+ return rebaseline_recheck(changed, added, removed) if discovery_moved?
295
+
234
296
  # ADR-67 WD6c lift — recompute the whole-project inferred-param table BEFORE deciding the closure,
235
297
  # and diff it against the snapshot's copy: an entry that moved (because a caller's argument type
236
298
  # changed, a caller appeared, or one vanished) invalidates the CALLEE's file and its symbol
@@ -238,21 +300,47 @@ module Rigor
238
300
  fresh_params = fresh_param_table(current, changed, added, removed)
239
301
  param_files, param_pairs = param_seed_invalidation(fresh_params)
240
302
  affected = affected_closure(changed, added, removed, param_files, param_pairs)
303
+ affected, analyze_set, runner, merged, reused =
304
+ run_settled_closure(affected, [current, previous, removed], fresh_params)
305
+ if served_effects_stale?(runner, reused) || synthetic_surface_moved?(runner, reused)
306
+ return rebaseline_recheck(changed, added, removed)
307
+ end
308
+
309
+ absorb(runner, current, analyze_set, removed)
310
+ @param_table = fresh_params
311
+ recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner,
312
+ stale_readers: @stale_plugin_readers)
313
+ end
314
+
315
+ # {#recheck}'s narrowed run. Issue #2020 — a stale run-scoped key whose chosen refresher registered nothing (a
316
+ # parse error, a plugin that raised, an edit that took the notice out of its text) is decided by its other
317
+ # holders, so they join the closure and it runs again. Returns `[affected, analyze_set, runner, merged, reused]`.
318
+ def run_settled_closure(affected, (current, previous, removed), fresh_params)
319
+ forced, analyze_set = forced_analyze_set(affected, current)
320
+ runner, merged, reused = run_closure(analyze_set, current, previous, forced, fresh_params)
321
+ fallback = @plugin_disclosures.unresolved_holders(runner.analysis_disclosures, analyze_set, @stale_disclosures,
322
+ removed.to_set)
323
+ return [affected, analyze_set, runner, merged, reused] if fallback.empty?
324
+
325
+ affected = (affected | fallback).freeze
326
+ @stale_plugin_readers |= fallback
241
327
  forced, analyze_set = forced_analyze_set(affected, current)
328
+ [affected, analyze_set, *run_closure(analyze_set, current, previous, forced, fresh_params)]
329
+ end
330
+ private :run_settled_closure
331
+
332
+ # One narrowed run of {#recheck}'s closure: the runner, its merged rows, and the files it served. The freshly
333
+ # collected table is handed to the runner so the run seeds from the SAME table the diff was decided on (and the
334
+ # collector runs once per recheck, not twice). The reused files' cached rows go in too, so the runner lists them
335
+ # in the full run's file order (#1524).
336
+ def run_closure(analyze_set, current, previous, forced, fresh_params)
242
337
  reused = (current & previous) - analyze_set.to_a
243
- # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
244
- # was decided on (and the collector runs once per recheck, not twice). The reused files' cached rows
245
- # go in too, so the runner lists them in the full run's file order (#1524).
246
338
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
247
339
  param_inferred_types: fresh_params, **forced_target_options(forced),
248
340
  restored_run_level_rows: @run_level_rows, **served_state(reused))
249
- merged = run_runner(runner).diagnostics
250
- return rebaseline_recheck(changed, added, removed) if served_effects_stale?(runner, reused)
251
-
252
- absorb(runner, current, analyze_set, removed)
253
- @param_table = fresh_params
254
- recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner)
341
+ [runner, run_runner(runner).diagnostics, reused]
255
342
  end
343
+ private :run_closure
256
344
 
257
345
  # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
258
346
  # file, for the CLI's `--incremental --tmp-file=X --instead-of=Y`. Returns the {Recheck} when the
@@ -304,9 +392,10 @@ module Rigor
304
392
  # records none at all), so no dependent set can bound who read it. An edit that moved it re-analyses the
305
393
  # whole project — what a `sig/` edit already costs through the snapshot fingerprint.
306
394
  def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
395
+ @stale_plugin_readers = plugin_read_affected | disclosure_refreshers(removed)
307
396
  return whole_project_closure(changed, added) if source_rbs_gate.moved?(@seed_bundles, changed, added, removed)
308
397
 
309
- gated_closure(changed, added, removed, param_files, param_pairs)
398
+ (gated_closure(changed, added, removed, param_files, param_pairs) | @stale_plugin_readers).freeze
310
399
  end
311
400
 
312
401
  # {#affected_closure} for an edit whose synthesized RBS is byte-identical in every file it touched.
@@ -350,17 +439,47 @@ module Rigor
350
439
  closure.freeze
351
440
  end
352
441
 
442
+ # Issue #1553 — every analysed file whose last analysis made a plugin read that no longer validates: a file
443
+ # read through {Plugin::IoBoundary#read_file} or probed through `#file?` / `#directory?` was edited,
444
+ # removed, or created after a read that found it absent, or a `#list_directory` / `#glob` listing moved.
445
+ # The ADR-46 edges record type and declaration reads only, so none of these moves a reader into the
446
+ # closure, and its cached rows were computed from the input as it was. The reader's own text is unchanged,
447
+ # so it joins the closure alone: a plugin read made while analysing a dependent is credited to that
448
+ # dependent. Each distinct row is validated once however many files made it (one `File.stat` for a
449
+ # content row whose stat tuple did not move, one re-glob for a listing row).
450
+ def plugin_read_affected
451
+ verdicts = {}
452
+ @plugin_reads.each_with_object(Set.new) do |(path, rows), stale|
453
+ next unless rows.any? { |row| !verdicts.fetch(row) { verdicts[row] = plugin_read_fresh?(row) } }
454
+
455
+ stale << path
456
+ end
457
+ end
458
+
459
+ def plugin_read_fresh?(row)
460
+ if row.is_a?(Cache::Descriptor::GlobEntry)
461
+ Cache::Descriptor::GlobEntry.fresh?(row)
462
+ else
463
+ Cache::Descriptor.new(files: [row]).fresh?
464
+ end
465
+ end
466
+
467
+ # Issue #1553 — `runner`'s per-file plugin reads for `paths`, each file's rows de-duplicated and frozen.
468
+ def plugin_reads_of(runner, paths)
469
+ runner.analysis_reads.slice(*paths).transform_values { |rows| rows.uniq.freeze }
470
+ end
471
+
353
472
  # Issue #1906 — a file that rewrites a constant's singleton names without declaring it
354
- # (`D.singleton_class.alias_method :own, :keep`) contributes opaque singleton envelopes to the constant's bucket,
355
- # but a reader's class edge names only the files that declared the constant when the reader last ran, and the
356
- # class-declaration diff does not move while the constant stays in the file (`:own` becoming `:keep`, or the
357
- # call becoming a `class D` reopening). So a declaration-unstable edit to such a file fires `class:<last
358
- # segment>` for each constant that, before the edit, both sat in the file's singleton-source set and held a
359
- # singleton envelope entry or an unpositioned singleton side the file recorded (a computed name there leaves the
360
- # side unpositioned, and becoming literal reads it again). A file that only includes into a constant
361
- # (`ActiveRecord::Base.include(X)`) records neither and fires nothing. A constant the edit makes the file
362
- # rewrite needs no key: the class-declaration diff fires if it is new to the file, and the reader's class edge
363
- # names the file if the file declared it.
473
+ # (`D.singleton_class.alias_method :own, :keep`, or, since #1941, `def D.own`) contributes opaque singleton
474
+ # envelopes to the constant's bucket, but a reader's class edge names only the files that declared the constant
475
+ # when the reader last ran, and the class-declaration diff does not move while the constant stays in the file
476
+ # (`:own` becoming `:keep`, or the call becoming a `class D` reopening). So a declaration-unstable edit to such a
477
+ # file fires `class:<last segment>` for each constant that, before the edit, both sat in the file's
478
+ # singleton-source set and held a singleton envelope entry or an unpositioned singleton side the file recorded (a
479
+ # computed name there leaves the side unpositioned, and becoming literal reads it again). A file that only
480
+ # includes into a constant (`ActiveRecord::Base.include(X)`) records neither and fires nothing. A constant the
481
+ # edit makes the file rewrite needs no key: the class-declaration diff fires if it is new to the file, and the
482
+ # reader's class edge names the file if the file declared it.
364
483
  def singleton_rewrite_affected(unstable)
365
484
  names = unstable.flat_map { |path| singleton_rewritten_constants(@seed_bundles[path]) }.uniq
366
485
  return Set.new if names.empty?
@@ -383,8 +502,8 @@ module Rigor
383
502
  # exempts such a write is a fact of the whole program — an alias, or a `write` / `to_str` / `to_int` / hatch
384
503
  # defined in any spelling on any receiver, a rewritten ancestor, a mixin — which no name-keyed edge can
385
504
  # follow, so a stale report would outlive the edit that exempts it. Such files are rare, so this costs little.
386
- # A file the census silenced is not re-checked when the exemption goes away: that stale answer misses a
387
- # report, it never makes one.
505
+ # A file the census silenced is re-checked through the `global_census:` keys its rule recorded
506
+ # ({#global_census_affected}, issue #2158).
388
507
  def global_write_reporters
389
508
  @cache.each_with_object(Set.new) do |(path, diagnostics), reporters|
390
509
  reporters << path if diagnostics.any? { |diagnostic| diagnostic.rule.to_s.start_with?("global.") }
@@ -532,6 +651,32 @@ module Rigor
532
651
  run_runner(runner).diagnostics
533
652
  end
534
653
 
654
+ # Issue #1967 — the edit replay of `--verify-incremental-edits`: a warm recheck of this session's baseline with
655
+ # `buffer` standing in for one file, the way editor mode option B (#146) rechecks an unsaved edit. The baseline is
656
+ # handed over as a Marshal copy of the snapshot a `--incremental` run would save, through a fresh session that
657
+ # never persists, so this session's own state is untouched and each replay starts from the same baseline. Unlike
658
+ # {#reanalyze_subset}, whose tree is the baseline's, the edit moves what the served files' rows were computed
659
+ # from, so a dependent the recheck fails to re-analyse is served stale and the comparison sees it. Returns the
660
+ # {Recheck}, or nil when the recheck declined as option B does (the plugin fact surface moved, an unverified
661
+ # source-RBS gate, a re-baseline), where the answer would be a full analysis and there is nothing to compare.
662
+ def replay_edit(buffer)
663
+ adopt_plugin_fact_fingerprint if @plugin_fact_reusable.nil?
664
+ replay = IncrementalSession.new(
665
+ configuration: @configuration, paths: @paths, environment: @environment, cache_store: @cache_store,
666
+ plugin_requirer: @plugin_requirer, buffer: buffer, explain: @explain
667
+ )
668
+ replay.run_buffer_recheck(snapshot: ReplaySnapshot.new(blob: Marshal.dump(to_payload)), fingerprint: :replay)
669
+ end
670
+
671
+ # The in-memory stand-in for {Cache::IncrementalSnapshot} {#replay_edit} hands its replay session: every load is a
672
+ # fresh copy, as a load from disk is.
673
+ ReplaySnapshot = Data.define(:blob) do
674
+ def load(fingerprint:) # rubocop:disable Lint/UnusedMethodArgument
675
+ Marshal.load(blob) # rubocop:disable Security/MarshalLoad
676
+ end
677
+ end
678
+ private_constant :ReplaySnapshot
679
+
535
680
  # Cross-process incremental run (the `--incremental` flag's engine). With a disk `snapshot` whose
536
681
  # `fingerprint` matches, restore the prior per-file state and `#recheck` (re-analyze only the changed
537
682
  # closure, serve the rest from the restored cache); otherwise run a full `#baseline`. Either way,
@@ -669,7 +814,7 @@ module Rigor
669
814
  # the gate could not see. {#run_incremental} re-analyses the project and {#run_buffer_recheck} declines,
670
815
  # the way each treats a moved plugin fact surface. A run that changed nothing is exempt: under an
671
816
  # untrusted gate every earlier edit already re-analysed the whole project, so the snapshot it serves is
672
- # whole, and a null run stays one.
817
+ # whole, and a null run stays one. A recheck that found a stale plugin read (#1553) is a change.
673
818
  def source_rbs_unverified?(result)
674
819
  source_rbs_gate.untrusted? && !result.no_change? && !result.reused.empty?
675
820
  end
@@ -889,7 +1034,7 @@ module Rigor
889
1034
  @analyzed = payload.analyzed
890
1035
  @cache = payload.cache
891
1036
  @sources = payload.sources
892
- @digests = payload.digests
1037
+ restore_digests(payload)
893
1038
  # ADR-85 WD2 — restore the per-file discovery bundles if present (absent in a pre-#85 snapshot → empty,
894
1039
  # so the recheck's discovery re-walks every file: a cold-quality index, always sound). The SCHEMA bump
895
1040
  # makes a genuinely stale-shaped bundle unreadable rather than mis-folded.
@@ -903,6 +1048,7 @@ module Rigor
903
1048
  restore_structural_declarations(payload)
904
1049
  @return_summaries = payload.return_summaries || {}
905
1050
  @param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
1051
+ restore_plugin_records(payload)
906
1052
  # Issues #796 / #794 — absent (nil) in a snapshot written before schema 22 is impossible (the SCHEMA
907
1053
  # gate rejects one), so nil here means the writing run genuinely had nothing to say.
908
1054
  @run_level_rows = payload.run_level_rows
@@ -918,6 +1064,14 @@ module Rigor
918
1064
  @negative_dependents = Incremental.invert(@missing)
919
1065
  end
920
1066
 
1067
+ # Issue #1553 — the plugin reads each file's last analysis made, and (#2020) the run-scoped plugin rows it
1068
+ # registered; `{}` in a payload built without them. Extracted to keep {#restore} under its ABC budget.
1069
+ def restore_plugin_records(payload)
1070
+ @plugin_reads = payload.plugin_reads
1071
+ @plugin_disclosures = RunDisclosureLedger.load(payload.plugin_disclosures)
1072
+ @synthetic_surface_digest = payload.synthetic_surface_digest # the plugin-built ADR-16 index (PR #2286 M1)
1073
+ end
1074
+
921
1075
  # ADR-46 slice 3 / issue #644 — the structural tier's negative-edge state: the per-consumer missed
922
1076
  # lookups plus the two per-file DECLARATION sets (`class_decls`, `constant_decls`) whose diff says what
923
1077
  # APPEARED in an edit. Absent in an older snapshot → empty, which only loses the appeared-symbol
@@ -942,7 +1096,11 @@ module Rigor
942
1096
  effect_collections: marshal_safe_effect_collections,
943
1097
  effects_identity: @effects_identity,
944
1098
  run_level_rows: @run_level_rows,
945
- effect_plugin_digest: @effect_plugin_digest
1099
+ effect_plugin_digest: @effect_plugin_digest,
1100
+ synthetic_surface_digest: @synthetic_surface_digest,
1101
+ plugin_reads: @plugin_reads,
1102
+ plugin_disclosures: @plugin_disclosures.to_h,
1103
+ discovery_digests: @discovery_digests
946
1104
  )
947
1105
  end
948
1106
 
@@ -1054,15 +1212,20 @@ module Rigor
1054
1212
  whole_project = current.all? { |path| analyze_set.include?(path) }
1055
1213
  @seed_bundles = source_rbs_gate.stamp(runner.seed_bundles, whole_project: whole_project)
1056
1214
  fresh_by_file = per_file(runner.per_file_diagnostics)
1215
+ # Issue #1553 — a re-analysed file's reads are this run's: a read it no longer makes stops guarding it.
1216
+ fresh_reads = plugin_reads_of(runner, analyze_set)
1057
1217
  analyze_set.each do |path|
1058
1218
  @cache[path] = fresh_by_file[path] || []
1059
1219
  @digests[path] = pack_digest(path)
1220
+ fresh_reads.key?(path) ? @plugin_reads[path] = fresh_reads[path] : @plugin_reads.delete(path)
1060
1221
  end
1222
+ absorb_disclosures(runner, analyze_set)
1061
1223
  forget_unbound_digests
1062
1224
  absorb_dependency_graph(runner)
1063
1225
  # Issues #796 / #794 — the recheck's own snapshots already carry the replayed rows folded together
1064
1226
  # with anything its closure demanded for itself, so this is the same union the next run replays.
1065
1227
  @run_level_rows = runner.run_level_rows
1228
+ @synthetic_surface_digest = synthetic_surface_digest_of(runner)
1066
1229
  refresh_return_summaries(runner, analyze_set)
1067
1230
  refresh_effect_collections(runner, analyze_set)
1068
1231
  end
@@ -1105,6 +1268,8 @@ module Rigor
1105
1268
  @cache.delete(path)
1106
1269
  @digests.delete(path)
1107
1270
  @sources.delete(path)
1271
+ @plugin_reads.delete(path)
1272
+ @plugin_disclosures.forget(path) # Unpinned hygiene: a removed file is never served, so never replayed.
1108
1273
  @symbol_sources.delete(path)
1109
1274
  @ancestry_sources.delete(path)
1110
1275
  @missing.delete(path)
@@ -1176,7 +1341,8 @@ module Rigor
1176
1341
  # top-level def, `method:C#m` otherwise), then unions the recorded negative-dependents of those keys.
1177
1342
  # Issue #1120 — plus the consumers of any refinement name the edit moved ({#refinement_affected}). Issue #1367 —
1178
1343
  # and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}). Issue #1459 — and
1179
- # the consumers of any method name a file started or stopped defining ({#defined_name_affected}).
1344
+ # the consumers of any method name a file started or stopped defining ({#defined_name_affected}). Issue #2134 —
1345
+ # and the consumers of any visibility slot a file moved ({#visibility_name_affected}).
1180
1346
  def negative_affected(changed, removed, new_fingerprints, new_class_decls, new_constant_decls, summary = nil)
1181
1347
  appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
1182
1348
  # Issue #639 — a class DISAPPEARING satisfies the `class:` kind too, now that a resolved bare
@@ -1193,8 +1359,61 @@ module Rigor
1193
1359
  keys = appeared_methods.map { |symbol| negative_key_for(symbol) }
1194
1360
  keys.concat(class_move_keys(moved_classes))
1195
1361
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
1196
- Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
1197
- defined_name_affected(changed + removed, summary) | global_write_reporters
1362
+ Incremental.negative_closure(keys, @negative_dependents) | table_diff_affected(changed + removed, summary) |
1363
+ global_write_reporters
1364
+ end
1365
+
1366
+ # The consumers of the name-keyed edges whose producers diff a per-file table between each file's seed bundle
1367
+ # and the scan.
1368
+ def table_diff_affected(paths, summary)
1369
+ refinement_affected(paths, summary) | defined_name_affected(paths, summary) |
1370
+ visibility_name_affected(paths, summary) | mark_affected(paths, summary) |
1371
+ global_census_affected(paths, summary) | ivar_set_affected(paths, summary)
1372
+ end
1373
+
1374
+ # Issue #1833 — the consumers whose answer read a class mark that moved in this edit. A reader of a class's
1375
+ # envelope bucket records `mark:<last segment>` (`Scope#parameter_envelopes_of`), whichever way it answered, so
1376
+ # a file that adds or drops only a mark (`x.singleton_class.prepend(Comparable)`, `o.extend(Helper)` in a
1377
+ # method body, a `refine` block's refined mark) re-checks it although the file declares nothing it read. A
1378
+ # reader that scans every bucket for an object-extended module (`SourceArity#object_extension_may_shadow?`)
1379
+ # records `mark:*`, which fires when an object-extended mark moves. The before-state is each touched file's
1380
+ # seed bundle, the after-state the scan's merged table.
1381
+ #
1382
+ # Issue #1661 — and `mixin:<qualified name>` for a class or module whose unpositioned mixin edges moved
1383
+ # ({Incremental.changed_unpositioned_owners}): an external chain entry records it for each name it can denote
1384
+ # (`ResolutionChain#record_hooks`), so a new file that taints `Comparable` with `include(*MODS)` re-checks the
1385
+ # readers of a chain through it. It is not `mark:`, which a plain reopening of a module moves (the bucket's
1386
+ # module mark) and which would re-check every reader of `Kernel` on any `module Kernel` reopening.
1387
+ def mark_affected(paths, summary)
1388
+ def_index = summary && summary[:def_index]
1389
+ before = paths.map { |path| @seed_bundles.dig(path, :parameter_envelopes) }
1390
+ moved = Incremental.changed_class_marks(before, [def_index && def_index[:parameter_envelopes]])
1391
+ tainted = Incremental.changed_unpositioned_owners(
1392
+ paths.map { |path| @seed_bundles.dig(path, :unpositioned_mixins) },
1393
+ [def_index && def_index[:unpositioned_mixins]]
1394
+ )
1395
+ return Set.new if moved.empty? && tainted.empty?
1396
+
1397
+ keys = moved.map { |(class_name, _mark)| "mark:#{class_name.split('::').last}" }.uniq
1398
+ keys.concat(mixin_keys(tainted))
1399
+ extended = Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK
1400
+ keys << "mark:*" if moved.any? { |(_class_name, mark)| mark == extended }
1401
+ Incremental.negative_closure(keys, @negative_dependents)
1402
+ end
1403
+
1404
+ # Issue #1661 — the `mixin:` keys an owner of a moved unpositioned edge fires. The table keys a receiver-form
1405
+ # owner by the spelling written (`class File; Constants.include(*MODS); end` is `"Constants"`), not the constant
1406
+ # Ruby resolves it to, while the chain files the qualified candidate (`mixin:File::Constants`). The table does
1407
+ # not carry the nesting, and every lexical-prefix candidate of the owner ends in `::<owner>`, so the owner fires
1408
+ # its own key and every recorded `mixin:` key ending in `::<owner>`. A relative spelling over-fires to the
1409
+ # readers of every module sharing its trailing segments; a qualified one fires only its suffix matches.
1410
+ def mixin_keys(owners)
1411
+ keys = owners.map { |name| "mixin:#{name}" }
1412
+ suffixes = owners.map { |name| "::#{name}" }
1413
+ @negative_dependents.each_key do |key|
1414
+ keys << key if key.start_with?("mixin:") && suffixes.any? { |suffix| key.end_with?(suffix) }
1415
+ end
1416
+ keys.uniq
1198
1417
  end
1199
1418
 
1200
1419
  # The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
@@ -1238,6 +1457,53 @@ module Rigor
1238
1457
  Incremental.negative_closure(names.map { |name| "defines:#{name}" }, @negative_dependents)
1239
1458
  end
1240
1459
 
1460
+ # Issue #2158 — the consumers whose `global.*` write rule read a census entry that a file in `paths` started or
1461
+ # stopped writing. The before-state is each file's seed bundle, the after-state the scan's per-file census. The
1462
+ # write the census silenced reports nothing, so {#global_write_reporters} does not reach it.
1463
+ def global_census_affected(paths, summary)
1464
+ after = (summary && summary[:global_write_census]) || {}
1465
+ before = paths.to_h { |path| [path, @seed_bundles.dig(path, :global_write_census)] }
1466
+ keys = Incremental.changed_global_census_keys(paths, before, after)
1467
+ return Set.new if keys.empty?
1468
+
1469
+ Incremental.negative_closure(keys, @negative_dependents)
1470
+ end
1471
+
1472
+ # Issue #1154 — the consumers whose declared-parameter ivar credit read an ivar writer entry a file in `paths`
1473
+ # started or stopped recording ({GlobalWriteCensus.ivar_key}: `ivar_set:@x`, `ivar_set_class:K`,
1474
+ # `own_write:K#@x`). The before-state is each file's seed bundle, the after-state the scan's per-file census. The
1475
+ # write sits in a method body, so neither the declaration signature nor the symbol fingerprints move with it.
1476
+ def ivar_set_affected(paths, summary)
1477
+ after = (summary && summary[:global_write_census]) || {}
1478
+ keys = Set.new
1479
+ paths.each do |path|
1480
+ census = @seed_bundles.dig(path, :global_write_census) || Inference::GlobalWriteCensus::EMPTY
1481
+ before = Inference::GlobalWriteCensus.ivar_set_entries(census)
1482
+ now = Inference::GlobalWriteCensus.ivar_set_entries(after[path] || Inference::GlobalWriteCensus::EMPTY)
1483
+ (before ^ now).each { |entry| keys << Inference::GlobalWriteCensus.ivar_key(entry) }
1484
+ end
1485
+ return Set.new if keys.empty?
1486
+
1487
+ Incremental.negative_closure(keys.to_a, @negative_dependents)
1488
+ end
1489
+
1490
+ # Issue #2134 — the consumers whose answer read a class's visibility slot for a method, or its contest, for each
1491
+ # slot whose entries a file in `paths` added, dropped or changed (`Scope#discovered_method_visibility`). The
1492
+ # before-state is each file's seed bundle, the after-state the scan's per-file entries. A `private :foo` in a
1493
+ # reopening or a `class_eval` block moves no `def`, so neither the symbol fingerprints nor the defined-name diff
1494
+ # sees it.
1495
+ def visibility_name_affected(paths, summary)
1496
+ after = (summary && summary[:visibility_entries]) || {}
1497
+ before = paths.to_h do |path|
1498
+ bundle = @seed_bundles[path]
1499
+ [path, bundle && Inference::ScopeIndexer.visibility_entries(bundle)]
1500
+ end
1501
+ slots = Incremental.changed_visibility_slots(paths, before, after)
1502
+ return Set.new if slots.empty?
1503
+
1504
+ Incremental.negative_closure(slots.map { |slot| "visibility:#{slot}" }, @negative_dependents)
1505
+ end
1506
+
1241
1507
  # The qualified class/module names declared in the pre-parsed `index` (shared with
1242
1508
  # {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
1243
1509
  def class_declarations_from_index(index)
@@ -1400,7 +1666,22 @@ module Rigor
1400
1666
  # and (#1898) their effect collections, so the run closes its effect graph over the whole project.
1401
1667
  def served_state(reused)
1402
1668
  { served_per_file_diagnostics: @cache.slice(*reused),
1403
- served_effect_collections: @effect_collections.slice(*reused) }
1669
+ served_effect_collections: @effect_collections.slice(*reused),
1670
+ served_run_disclosures: @plugin_disclosures.served(reused, @stale_disclosures) }
1671
+ end
1672
+
1673
+ # Issue #2020 — folds the run's run-scoped registrations into the ledger, refreshing the keys this recheck found
1674
+ # stale. Extracted to keep {#absorb} under its ABC budget.
1675
+ def absorb_disclosures(runner, analyze_set)
1676
+ @plugin_disclosures.absorb(runner.analysis_disclosures, analyze_set, @stale_disclosures)
1677
+ @stale_disclosures = Set.new
1678
+ end
1679
+
1680
+ # Issue #2020 — the run-scoped plugin rows whose reads went stale ({RunDisclosureLedger}): one registering file
1681
+ # each joins the closure, and their replays are withheld until {#absorb} refreshes them from that file.
1682
+ def disclosure_refreshers(removed)
1683
+ @stale_disclosures = @plugin_disclosures.stale_identities { |row| plugin_read_fresh?(row) }
1684
+ @plugin_disclosures.refreshers(@stale_disclosures, removed.to_set)
1404
1685
  end
1405
1686
 
1406
1687
  # Issue #1898 — the plugin effect-fact digest a run's collections were stamped under. A plugin writes its
@@ -1419,6 +1700,21 @@ module Rigor
1419
1700
  effect_plugin_digest_of(runner) != @effect_plugin_digest
1420
1701
  end
1421
1702
 
1703
+ # PR #2286 review M1 — whether the ADR-16 synthetic-method index `runner` built differs from the one the
1704
+ # served files were analysed under. The index comes from class-body DSL calls (`devise :confirmable`,
1705
+ # a Tier C `attribute`), which no declaration signature records and no dependency edge names, so a file
1706
+ # that dispatched through a synthesised member is not in the closure of the edit that removed it. The
1707
+ # comparison waits for the run because the index is built by its pre-pass; a move re-analyses the project.
1708
+ def synthetic_surface_moved?(runner, reused)
1709
+ return false if reused.empty?
1710
+
1711
+ synthetic_surface_digest_of(runner) != @synthetic_surface_digest
1712
+ end
1713
+
1714
+ def synthetic_surface_digest_of(runner)
1715
+ runner.synthetic_method_index&.surface_digest
1716
+ end
1717
+
1422
1718
  # The answer {#recheck} gives when {#served_effects_stale?}: a full baseline, reported as a recheck that
1423
1719
  # re-analysed every file. {#run_incremental} reads `rebaselined` so it neither runs a second baseline
1424
1720
  # nor reports the run as warm.
@@ -1429,9 +1725,10 @@ module Rigor
1429
1725
  end
1430
1726
 
1431
1727
  # A {Recheck} over `deltas` (`[changed, added, removed]`), counting `runner`'s template units.
1432
- def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false)
1728
+ def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false, stale_readers: Set.new)
1433
1729
  changed, added, removed = deltas.map(&:to_set)
1434
1730
  Recheck.new(diagnostics: diagnostics, changed: changed, added: added, removed: removed, affected: affected,
1731
+ stale_readers: stale_readers,
1435
1732
  reused: reused, templates: runner.template_unit_paths.size, rebaselined: rebaselined)
1436
1733
  end
1437
1734
 
@@ -1502,19 +1799,12 @@ module Rigor
1502
1799
  def rebind_buffer(files)
1503
1800
  return if @buffer.nil? || files.include?(@buffer.logical_path)
1504
1801
 
1505
- target = real_path(@buffer.logical_path)
1506
- return if target.nil?
1802
+ target = BufferBinding.real_path(@buffer.logical_path)
1507
1803
 
1508
- match = files.find { |path| real_path(path) == target }
1804
+ match = files.find { |path| BufferBinding.real_path(path) == target }
1509
1805
  @buffer = BufferBinding.new(logical_path: match, physical_path: @buffer.physical_path) if match
1510
1806
  end
1511
1807
 
1512
- def real_path(path)
1513
- File.realpath(path)
1514
- rescue StandardError
1515
- nil
1516
- end
1517
-
1518
1808
  # True when `path`'s recorded stat entry proves it unchanged since the last analysis. Any stat / parse
1519
1809
  # failure (missing entry, unreadable / vanished file) reads as NOT fresh (→ re-analyse), preserving the
1520
1810
  # prior `digest(path) != recorded` "changed" semantics for a file that cannot be validated.
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rooted_glob"
4
+ require_relative "diagnostic"
5
+
3
6
  module Rigor
4
7
  module Analysis
5
8
  # ADR-87 WD4 — project file-set expansion shared by the {Analysis::Runner} (miss path) and the
@@ -8,8 +11,13 @@ module Rigor
8
11
  # is taken verbatim; anything else contributes no files here (the Runner layers its own path-error
9
12
  # diagnostics and buffer / in-memory handling on top — none of which add or remove files for an ordinary
10
13
  # on-disk check, so the two file sets agree).
14
+ #
15
+ # #1991 — also the one place a path that names neither a directory nor a `.rb` file is worded and
16
+ # severity-graded, so `rigor check` and `rigor sig-gen` report the same path error the same way.
11
17
  module PathExpansion
12
18
  RUBY_GLOB = "**/*.rb"
19
+ NO_SUCH_PATH = "no such file or directory"
20
+ NOT_A_RUBY_FILE = "not a Ruby file (expected `.rb` or a directory)"
13
21
 
14
22
  module_function
15
23
 
@@ -26,8 +34,43 @@ module Rigor
26
34
  end
27
35
  end
28
36
 
37
+ # The `[path, message]` pairs for each of `paths` that names neither a directory nor a `.rb` file,
38
+ # worded as `rigor check` words them.
39
+ def unusable(paths)
40
+ Array(paths).each_with_object([]) do |path, bad|
41
+ next if File.directory?(path) || (File.file?(path) && path.end_with?(".rb"))
42
+
43
+ bad << [path, unusable_message(path)]
44
+ end
45
+ end
46
+
47
+ def unusable_message(path)
48
+ File.exist?(path) ? NOT_A_RUBY_FILE : NO_SUCH_PATH
49
+ end
50
+
51
+ # The error diagnostics for `bad` (from {unusable}). A bad path among valid ones is a warning that it was
52
+ # skipped; a bad path that leaves nothing to analyse is an error, so a lone typo is not masked.
53
+ def diagnostics(bad, any_files:)
54
+ severity = any_files ? :warning : :error
55
+ suffix = any_files ? " (skipped)" : ""
56
+ bad.map do |path, message|
57
+ Diagnostic.new(path: path, line: 1, column: 1, message: "#{message}#{suffix}", severity: severity)
58
+ end
59
+ end
60
+
29
61
  def directory_files(dir, exclude_patterns, base: Dir.pwd)
30
- reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns, base: base)
62
+ reject_excluded(RootedGlob.glob(dir, RUBY_GLOB), exclude_patterns, base: base)
63
+ end
64
+
65
+ # The configured project files, expanded as `rigor check` expands its configured paths: a directory globs,
66
+ # any other entry (a file, or a path that does not exist) is kept verbatim and left to the reader to skip.
67
+ # The file list a cross-file discovery seed is folded over ({Protection::DiscoverySeed}).
68
+ def configured_files(configuration)
69
+ Array(configuration.paths).flat_map do |path|
70
+ next [path] unless File.directory?(path)
71
+
72
+ directory_files(path, configuration.exclude_patterns, base: configuration.exclude_base)
73
+ end
31
74
  end
32
75
 
33
76
  # `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators