rigortype 0.4.0 → 0.4.2

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 (237) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/08-understanding-errors.md +3 -2
  25. data/docs/handbook/11-sig-gen.md +6 -0
  26. data/docs/handbook/appendix-liskov.md +5 -0
  27. data/docs/install.md +87 -15
  28. data/docs/manual/02-cli-reference.md +2 -2
  29. data/docs/manual/03-configuration.md +6 -1
  30. data/docs/manual/04-diagnostics.md +2 -1
  31. data/docs/manual/06-baseline.md +2 -0
  32. data/docs/manual/07-plugins.md +1 -1
  33. data/docs/manual/08-skills.md +4 -3
  34. data/docs/manual/12-caching.md +43 -4
  35. data/docs/manual/14-rails-quickstart.md +19 -11
  36. data/docs/manual/18-removing-dead-code.md +32 -2
  37. data/docs/manual/plugins/README.md +6 -1
  38. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  39. data/docs/manual/plugins/rigor-alba.md +60 -0
  40. data/docs/manual/plugins/rigor-minitest.md +22 -4
  41. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  42. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  43. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  44. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  45. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +184 -61
  47. data/lib/rigor/analysis/check_rules.rb +407 -144
  48. data/lib/rigor/analysis/dependency_recorder.rb +215 -18
  49. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  50. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  51. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  52. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  53. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  54. data/lib/rigor/analysis/incremental.rb +29 -9
  55. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +291 -0
  56. data/lib/rigor/analysis/incremental_run_slot.rb +173 -0
  57. data/lib/rigor/analysis/incremental_session.rb +287 -50
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  59. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  60. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +43 -9
  64. data/lib/rigor/analysis/run_stats.rb +2 -2
  65. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +88 -6
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +52 -13
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  68. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  69. data/lib/rigor/analysis/runner.rb +371 -49
  70. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  71. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  72. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  73. data/lib/rigor/analysis/template_units.rb +15 -5
  74. data/lib/rigor/analysis/worker_session.rb +11 -7
  75. data/lib/rigor/cache/descriptor.rb +40 -1
  76. data/lib/rigor/cache/file_digest.rb +28 -0
  77. data/lib/rigor/cache/incremental_snapshot.rb +92 -8
  78. data/lib/rigor/cache/store.rb +63 -3
  79. data/lib/rigor/cache/value_digest.rb +325 -0
  80. data/lib/rigor/cli/annotate_command.rb +5 -0
  81. data/lib/rigor/cli/baseline_command.rb +7 -18
  82. data/lib/rigor/cli/check_command.rb +85 -14
  83. data/lib/rigor/cli/doctor_command.rb +1 -1
  84. data/lib/rigor/cli/effects_report.rb +2 -2
  85. data/lib/rigor/cli/trace_command.rb +5 -0
  86. data/lib/rigor/cli/type_of_command.rb +5 -3
  87. data/lib/rigor/cli/unused_command.rb +16 -10
  88. data/lib/rigor/cli.rb +11 -3
  89. data/lib/rigor/config_audit.rb +3 -2
  90. data/lib/rigor/config_path.rb +29 -0
  91. data/lib/rigor/configuration/severity_profile.rb +11 -0
  92. data/lib/rigor/configuration.rb +52 -7
  93. data/lib/rigor/effects/plugin_facts.rb +6 -6
  94. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  95. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  96. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  97. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  98. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  99. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  100. data/lib/rigor/environment/rbs_loader.rb +284 -15
  101. data/lib/rigor/environment/required_features.rb +206 -0
  102. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  103. data/lib/rigor/environment.rb +34 -73
  104. data/lib/rigor/inference/acceptance.rb +28 -3
  105. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  106. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  107. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  108. data/lib/rigor/inference/captured_locals.rb +7 -1
  109. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  110. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  111. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  112. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  113. data/lib/rigor/inference/declaration_walk.rb +7 -0
  114. data/lib/rigor/inference/def_node_resolver.rb +26 -3
  115. data/lib/rigor/inference/definer_resolution.rb +266 -0
  116. data/lib/rigor/inference/expression_typer.rb +351 -61
  117. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  118. data/lib/rigor/inference/global_write_census.rb +7 -4
  119. data/lib/rigor/inference/guard_rebinding.rb +54 -13
  120. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  121. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  122. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  123. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  124. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  125. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  126. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  127. data/lib/rigor/inference/macro_block_self_type.rb +73 -14
  128. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  129. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  130. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  131. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  132. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  133. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  134. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  135. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  136. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +305 -54
  137. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  138. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  139. data/lib/rigor/inference/module_function_state.rb +214 -0
  140. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  141. data/lib/rigor/inference/narrowing.rb +166 -22
  142. data/lib/rigor/inference/object_mixins.rb +180 -0
  143. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  144. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  145. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  146. data/lib/rigor/inference/refine_census.rb +239 -0
  147. data/lib/rigor/inference/refine_self.rb +317 -0
  148. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  149. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  150. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  151. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  152. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  153. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  154. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  155. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +143 -0
  156. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  157. data/lib/rigor/inference/scope_indexer.rb +2129 -354
  158. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  159. data/lib/rigor/inference/statement_evaluator.rb +149 -32
  160. data/lib/rigor/inference/string_mutation.rb +9 -3
  161. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  162. data/lib/rigor/inference/version_guard.rb +19 -10
  163. data/lib/rigor/language_server/project_context.rb +31 -0
  164. data/lib/rigor/language_server/server.rb +18 -0
  165. data/lib/rigor/plugin/base.rb +28 -8
  166. data/lib/rigor/plugin/inflector.rb +1 -1
  167. data/lib/rigor/plugin/io_boundary.rb +124 -7
  168. data/lib/rigor/plugin/isolation.rb +1 -1
  169. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  170. data/lib/rigor/plugin/trust_policy.rb +3 -3
  171. data/lib/rigor/project_environment.rb +29 -6
  172. data/lib/rigor/protection/discovery_seed.rb +18 -9
  173. data/lib/rigor/protection/mutation_cache.rb +4 -4
  174. data/lib/rigor/protection/mutator.rb +1 -1
  175. data/lib/rigor/reflection/constant_ancestors.rb +211 -46
  176. data/lib/rigor/reflection/constant_path.rb +33 -7
  177. data/lib/rigor/reflection.rb +41 -10
  178. data/lib/rigor/scope/discovery_index.rb +258 -3
  179. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  180. data/lib/rigor/scope/resolution_chain.rb +1032 -0
  181. data/lib/rigor/scope.rb +480 -328
  182. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  183. data/lib/rigor/sig_gen/classification.rb +4 -1
  184. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  185. data/lib/rigor/sig_gen/generator.rb +77 -21
  186. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  187. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  188. data/lib/rigor/source/alias_names.rb +52 -0
  189. data/lib/rigor/source/literals.rb +3 -2
  190. data/lib/rigor/source/symbol_name.rb +20 -0
  191. data/lib/rigor/type/combinator.rb +59 -3
  192. data/lib/rigor/type/float_range.rb +4 -1
  193. data/lib/rigor/type/nominal.rb +6 -1
  194. data/lib/rigor/version.rb +1 -1
  195. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  196. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  197. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  198. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  199. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  200. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  201. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  202. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  203. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  204. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  207. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  208. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  209. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  210. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  211. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  212. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  213. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  214. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  215. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  216. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  217. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +13 -2
  218. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  219. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  220. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  221. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  222. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  223. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  224. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  225. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  226. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  227. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  228. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  229. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  230. data/sig/rigor/environment.rbs +2 -0
  231. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  232. data/sig/rigor/scope.rbs +69 -4
  233. data/sig/rigor/source.rbs +4 -0
  234. data/sig/rigor.rbs +3 -1
  235. data/skills/rigor-project-init/SKILL.md +37 -11
  236. data/skills/rigor-project-init/references/02-configure.md +7 -2
  237. metadata +90 -1
@@ -2,7 +2,10 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "incremental"
5
+ require_relative "incremental_run_slot"
6
+ require_relative "incremental_run_slot/write_guard"
5
7
  require_relative "plugin_fact_fingerprint"
8
+ require_relative "source_rbs_gate"
6
9
  require_relative "../cache/file_digest"
7
10
  require_relative "../cache/incremental_snapshot"
8
11
  require_relative "../effects/file_collection"
@@ -22,10 +25,11 @@ module Rigor
22
25
  # only `ΔF ∪ dependents[ΔF]`, and serves every other analyzed file from the cache — the body tier.
23
26
  #
24
27
  # The invariant the verify harness (and the spec) assert: `#recheck`'s merged diagnostics are
25
- # byte-identical (as a sorted set) to a full `--no-cache` re-analysis of the edited tree. This is the
26
- # `--verify-incremental` acceptance gate, here without disk persistence or CLI wiring (the cache is
27
- # in-process). It models the body tier only: an edit that adds / removes / moves a *file* is outside
28
- # the analyzed set it maintains and falls to a fresh {#baseline} (the structural tier is a later slice).
28
+ # byte-identical to a full `--no-cache` re-analysis of the edited tree, in the same order (#1524; the gate
29
+ # itself compares sorted sets). This is the `--verify-incremental` acceptance gate, here without disk
30
+ # persistence or CLI wiring (the cache is in-process). It models the body tier only: an edit that adds /
31
+ # removes / moves a *file* is outside the analyzed set it maintains and falls to a fresh {#baseline} (the
32
+ # structural tier is a later slice).
29
33
  # The class-length budget is relaxed: this is one cohesive orchestrator of the incremental state
30
34
  # (per-file diagnostics cache, the file-level / symbol-level / negative dependency graphs, and the ADR-85
31
35
  # seed bundles), clearer read together than split across micro-classes that would all share the same ivars.
@@ -159,6 +163,7 @@ module Rigor
159
163
  # Full baseline analysis with recording. Returns the run's diagnostics; populates the in-process cache
160
164
  # + dependency state.
161
165
  def baseline
166
+ @slot_carried_from = nil # ADR-45 WD2 — see {#load_snapshot}
162
167
  runner = build_runner(record_dependencies: true)
163
168
  diagnostics = run_runner(runner).diagnostics
164
169
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
@@ -167,7 +172,9 @@ module Rigor
167
172
  # must never read as a file that VANISHED from the project on the next recheck (which is what
168
173
  # `previous - current` would say, `current_files` being a `.rb` expansion).
169
174
  @analyzed = runner.analyzed_files - runner.template_unit_paths
170
- @seed_bundles = runner.seed_bundles # ADR-85 WD2 — the freshly built bundle set for the next run.
175
+ # ADR-85 WD2 — the freshly built bundle set for the next run, each stamped with its file's source-RBS
176
+ # digest (issue #1536).
177
+ @seed_bundles = source_rbs_gate.stamp(runner.seed_bundles, whole_project: true)
171
178
  absorb_dependency_graph(runner)
172
179
  @return_summaries = runner.return_summaries # ADR-89 WD2 — the full-run behavioural surface.
173
180
  # ADR-67 WD6c lift — the seed table the runner's own pre-pass computed ({} when the gate is off).
@@ -178,6 +185,7 @@ module Rigor
178
185
  @effects_identity = current_effects_identity
179
186
  @cache = per_file(runner.per_file_diagnostics)
180
187
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
188
+ forget_unbound_digests
181
189
  @run_level_rows = runner.run_level_rows
182
190
  diagnostics
183
191
  end
@@ -199,15 +207,16 @@ module Rigor
199
207
  param_files, param_pairs = param_seed_invalidation(fresh_params)
200
208
  affected = affected_closure(changed, added, removed, param_files, param_pairs)
201
209
  analyze_set = affected & current
210
+ reused = (current & previous) - affected.to_a
202
211
  # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
203
- # was decided on (and the collector runs once per recheck, not twice).
212
+ # was decided on (and the collector runs once per recheck, not twice). The reused files' cached rows
213
+ # go in too, so the runner lists them in the full run's file order (#1524).
204
214
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
205
215
  param_inferred_types: fresh_params,
206
- restored_run_level_rows: @run_level_rows)
207
- fresh = run_runner(runner).diagnostics
216
+ restored_run_level_rows: @run_level_rows,
217
+ served_per_file_diagnostics: @cache.slice(*reused))
218
+ merged = run_runner(runner).diagnostics
208
219
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
209
- reused = (current & previous) - affected.to_a
210
- merged = fresh + reused.flat_map { |path| @cache[path] || [] }
211
220
  absorb(runner, current, analyze_set, removed)
212
221
  @param_table = fresh_params
213
222
  Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
@@ -235,6 +244,7 @@ module Rigor
235
244
  # stale. A full baseline is the sound answer for `--incremental`, but in editor mode it is also the
236
245
  # latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
237
246
  break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
247
+ break nil if source_rbs_unverified?(result)
238
248
 
239
249
  result
240
250
  end
@@ -253,7 +263,20 @@ module Rigor
253
263
  # return exactly the way a body edit does, so it reuses the same audited dependents machinery. The
254
264
  # pairs join AFTER the ADR-89 WD2 behavioural-stability pruning: that gate re-evaluates returns under
255
265
  # the snapshot's OLD seeds, which is the wrong oracle for a pair whose seeds are the thing that moved.
266
+ #
267
+ # Issue #1536 (ADR-89 WD1 amendment) — every gate in {#gated_closure} reads the RUBY side of a file. What a
268
+ # source-RBS synthesizer derives from its comments reaches the environment as a `virtual:` buffer, and a
269
+ # reader of that RBS records no edge to the file it came from (an `attr_reader :name #: String` consumer
270
+ # records none at all), so no dependent set can bound who read it. An edit that moved it re-analyses the
271
+ # whole project — what a `sig/` edit already costs through the snapshot fingerprint.
256
272
  def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
273
+ return whole_project_closure(changed, added) if source_rbs_gate.moved?(@seed_bundles, changed, added, removed)
274
+
275
+ gated_closure(changed, added, removed, param_files, param_pairs)
276
+ end
277
+
278
+ # {#affected_closure} for an edit whose synthesized RBS is byte-identical in every file it touched.
279
+ def gated_closure(changed, added, removed, param_files, param_pairs)
257
280
  scan = changed + added
258
281
  # Parse the changed / added set ONCE for the per-symbol fingerprints, the class declarations, AND the
259
282
  # ADR-89 WD1 declaration signatures. They were separate `discovered_def_index_for_paths` passes over
@@ -396,13 +419,11 @@ module Rigor
396
419
  # dependents skippable) when its current {ScopeIndexer.declaration_signature} matches the one stored in
397
420
  # the snapshot's seed bundle — i.e. its edit changed no method signature, visibility, ancestry, member
398
421
  # layout, def line, or method existence, so every cross-file DECLARATION fact its dependents consume is
399
- # unchanged. Falls back to treating EVERY changed file as unstable (today's full closure) when a
400
- # comment-ingesting plugin is loaded — such a plugin reads the very comments the signature ignores, so a
401
- # comment edit it treats as a no-op could change a cross-file type. Sorbet sigs / dry-types includes are
402
- # CODE, captured by ADR-88's plugin-fact fingerprint (WD3), so only comment-as-input plugins escape here.
422
+ # unchanged. The signature reads code, not comments; the other half of stability — that no source-RBS
423
+ # synthesizer's output for the file moved — is settled before this runs ({SourceRbsGate#moved?}), so every
424
+ # file reaching here has a byte-identical synthesized contribution. Sorbet sigs / dry-types includes are
425
+ # CODE, captured by ADR-88's plugin-fact fingerprint (WD3).
403
426
  def declaration_unstable(changed, declaration_signatures)
404
- return changed if comment_ingesting_plugin_loaded?
405
-
406
427
  changed.reject { |path| declaration_stable?(path, declaration_signatures[path]) }
407
428
  end
408
429
 
@@ -415,14 +436,11 @@ module Rigor
415
436
  bundle[:declaration_signature] == current_declaration_signature
416
437
  end
417
438
 
418
- def comment_ingesting_plugin_loaded?
419
- # Mirrors the plugin loader's gem-name resolution (`ProjectPrePasses#trusted_gem_name`): a String
420
- # entry IS the gem name; a Hash entry names it under `"gem"` (or the manifest `"id"`).
421
- @configuration.plugins.any? do |entry|
422
- name = entry.is_a?(Hash) ? (entry["gem"] || entry["id"]) : entry
423
- COMMENT_INGESTING_PLUGIN_IDS.include?(name.to_s)
424
- end
439
+ # Every file this recheck can analyse: the removed ones drop out at `affected & current`.
440
+ def whole_project_closure(changed, added)
441
+ (@analyzed.to_set | changed | added).freeze
425
442
  end
443
+ private :gated_closure, :whole_project_closure
426
444
 
427
445
  # The current project file set (cheap directory expansion, no analysis), used to detect files added /
428
446
  # removed since the last run. Also where an editor buffer's binding is re-spelled onto the set, since
@@ -441,13 +459,13 @@ module Rigor
441
459
  # mutating session state. Returns the merged diagnostics.
442
460
  def reanalyze_subset(subset)
443
461
  affected = subset.to_set
462
+ reused = @analyzed - affected.to_a
444
463
  # ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
445
464
  # exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
446
465
  runner = build_runner(analyze_only: affected, param_inferred_types: @param_table,
447
- restored_run_level_rows: @run_level_rows)
448
- fresh = run_runner(runner).diagnostics
449
- reused = @analyzed - affected.to_a
450
- fresh + reused.flat_map { |path| @cache[path] || [] }
466
+ restored_run_level_rows: @run_level_rows,
467
+ served_per_file_diagnostics: @cache.slice(*reused))
468
+ run_runner(runner).diagnostics
451
469
  end
452
470
 
453
471
  # Cross-process incremental run (the `--incremental` flag's engine). With a disk `snapshot` whose
@@ -468,7 +486,7 @@ module Rigor
468
486
  # shares one digest memo across change detection and the baseline/absorb re-pack. The inner
469
487
  # `Runner#run` nests its own `with_run` for the analysis descriptors; nesting is safe (each restores).
470
488
  Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
471
- restored = fingerprint && snapshot.load(fingerprint: fingerprint)
489
+ restored = load_snapshot(snapshot, fingerprint)
472
490
  # ADR-88 WD1 — the plugin fact-surface fingerprint gates snapshot reuse the same way the global
473
491
  # fingerprint gates the load: a plugin sig/catalog edit outside `signature_paths:` (a Sorbet `.rbi`)
474
492
  # changes the types unchanged call sites resolve, without moving any analyzed file, so the global
@@ -489,7 +507,7 @@ module Rigor
489
507
  result = recheck
490
508
  adopt_plugin_fact_fingerprint
491
509
  fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
492
- if fact_reusable && effects_reusable
510
+ if fact_reusable && effects_reusable && !source_rbs_unverified?(result)
493
511
  diagnostics = result.diagnostics
494
512
  warm = true
495
513
  # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
@@ -497,9 +515,10 @@ module Rigor
497
515
  # A cold baseline always persists — there was no valid snapshot to reuse.
498
516
  skip_save = result.no_change?
499
517
  else
500
- # The fact surface moved (a plugin sig/catalog edit), a plugin is opaque, or the effects
501
- # identity moved: the cached-served files the recheck merged may be stale (or, for effects, a
502
- # partial collection cannot be closed), so re-analyze the whole tree. The current fact-surface
518
+ # The fact surface moved (a plugin sig/catalog edit), a plugin is opaque, the effects identity
519
+ # moved, or a synthesizer the source-RBS gate could not see was loaded (#1536): the cached-served
520
+ # files the recheck merged may be stale (or, for effects, a partial collection cannot be closed),
521
+ # so re-analyze the whole tree. The current fact-surface
503
522
  # digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
504
523
  # Only a genuine fact-surface reason sets the reporting flag the CLI banner reads.
505
524
  @fact_surface_invalidated = true unless fact_reusable
@@ -513,7 +532,7 @@ module Rigor
513
532
  warm = false
514
533
  skip_save = false
515
534
  end
516
- snapshot.save(fingerprint: fingerprint, payload: to_payload) if persist && fingerprint && !skip_save
535
+ persist_run(snapshot, fingerprint, diagnostics, skip_save: skip_save) if persist && fingerprint
517
536
  [diagnostics, warm]
518
537
  end
519
538
  end
@@ -538,11 +557,41 @@ module Rigor
538
557
 
539
558
  # ADR-88 WD1 — capture this invocation's fact-surface fingerprint (from the last analysis runner) onto the
540
559
  # reporting ivars + the `@plugin_fact_reusable` decision object.
560
+ #
561
+ # Issue #1536 — the same prepared registry settles whether the synthesizers the session digested with
562
+ # are the ones the run's environment was built from ({SourceRbsGate#verify}). A plugin that builds its
563
+ # synthesizer in `#prepare` leaves the gate untrusted, and every stored digest is stamped unknown before
564
+ # the snapshot is saved.
541
565
  def adopt_plugin_fact_fingerprint
542
- fact = compute_plugin_fact_fingerprint
566
+ registry = prepared_plugin_registry
567
+ fact = compute_plugin_fact_fingerprint(registry)
543
568
  @plugin_fact_digest = fact.digest
544
569
  @opaque_plugin_ids = fact.opaque_plugin_ids
545
570
  @plugin_fact_reusable = fact
571
+ @seed_bundles = source_rbs_gate.verify(registry, @seed_bundles)
572
+ end
573
+
574
+ # Issue #1536 — a recheck of an edit whose closure left some file out may have under-read a synthesizer
575
+ # the gate could not see. {#run_incremental} re-analyses the project and {#run_buffer_recheck} declines,
576
+ # the way each treats a moved plugin fact surface. A run that changed nothing is exempt: under an
577
+ # untrusted gate every earlier edit already re-analysed the whole project, so the snapshot it serves is
578
+ # whole, and a null run stays one.
579
+ def source_rbs_unverified?(result)
580
+ source_rbs_gate.untrusted? && !result.no_change? && !result.reused.empty?
581
+ end
582
+
583
+ # Issue #1536 — a file the gate could not stamp because it was saved after the closure was decided (its
584
+ # readers' cached answers predate that save) must read as changed on the next run, where its unknown
585
+ # stamp sends the edit to the whole project. Recording its post-save content digest would hide it.
586
+ def forget_unbound_digests
587
+ source_rbs_gate.unbound.each { |path| @digests.delete(path) }
588
+ end
589
+
590
+ # Issue #1536 — decides whether an edit moved synthesized RBS, and stamps each seed bundle's digest. One
591
+ # per session: its untrusted state must outlive every run the session makes.
592
+ def source_rbs_gate
593
+ @source_rbs_gate ||= SourceRbsGate.new(configuration: @configuration, cache_store: @cache_store,
594
+ plugin_requirer: @plugin_requirer)
546
595
  end
547
596
 
548
597
  # ADR-88 WD1 — the plugin fact-surface fingerprint for this invocation. For a SEQUENTIAL run it is read
@@ -551,17 +600,195 @@ module Rigor
551
600
  # published facts — there it falls back to the always-sequential probe. Both paths compute the identical
552
601
  # digest for a given fact surface, so the reuse decision is worker-count-independent (the parity spec
553
602
  # asserts this).
554
- def compute_plugin_fact_fingerprint
603
+ def compute_plugin_fact_fingerprint(registry = prepared_plugin_registry)
604
+ PluginFactFingerprint.from_registry(registry)
605
+ end
606
+
607
+ # The registry the run's environment was built from, after `#prepare`: the analysis runner's own on a
608
+ # sequential run, the always-sequential probe's on a pooled one (nil when the probe fails).
609
+ def prepared_plugin_registry
555
610
  registry = @last_runner&.plugin_registry
556
- if @workers.zero? && registry && !registry.empty?
557
- PluginFactFingerprint.from_registry(registry)
558
- else
559
- PluginFactFingerprint.compute(
560
- configuration: @configuration, cache_store: @cache_store, plugin_requirer: @plugin_requirer
561
- )
611
+ return registry if @workers.zero? && registry && !registry.empty?
612
+
613
+ PluginFactFingerprint.prepared_registry(
614
+ configuration: @configuration, cache_store: @cache_store, plugin_requirer: @plugin_requirer
615
+ )
616
+ end
617
+
618
+ # Loads the snapshot, noting (ADR-45 WD2) which write of the file it read: the identity is kept only when
619
+ # the file was the same before and after the read, since a rewrite in between leaves no telling which one
620
+ # the payload came from.
621
+ def load_snapshot(snapshot, fingerprint)
622
+ @slot_guard = start_slot_guard(fingerprint)
623
+ before = snapshot_identity(snapshot)
624
+ restored = fingerprint && snapshot.load(fingerprint: fingerprint)
625
+ @restored_snapshot_identity = restored && before == snapshot_identity(snapshot) ? before : nil
626
+ # The path set the previous run-result slot is keyed by, for a recheck to carry its chain from; a
627
+ # {#baseline} clears it, since a full run starts a chain of its own.
628
+ @slot_carried_from = restored&.analyzed
629
+ restored
630
+ end
631
+
632
+ # ADR-87 WD3 — a warm recheck that changed nothing skips the snapshot rewrite: the file it restored is still
633
+ # the one on disk, which is what the run-result slot records as its snapshot.
634
+ def persist_run(snapshot, fingerprint, diagnostics, skip_save:)
635
+ written = if skip_save
636
+ @restored_snapshot_identity
637
+ elsif snapshot.save(fingerprint: fingerprint, payload: to_payload)
638
+ snapshot_identity(snapshot)
639
+ end
640
+ write_run_slot(diagnostics, written_identity: written)
641
+ end
642
+
643
+ # ADR-45 WD2 (#1507) — records this run's answer as the run-result slot a later `rigor check --incremental`
644
+ # serves without loading the engine ({IncrementalRunSlot}). The slot's dependency descriptor must validate
645
+ # what the answer was computed from, and after a narrowed recheck most of the answer was computed by earlier
646
+ # runs, so it is assembled from three sources:
647
+ #
648
+ # - the analysed files, at the digest this session holds for each: the bytes its cached or fresh rows were
649
+ # computed from, never a re-digest taken after the fact;
650
+ # - what this run read and re-derives every run ({Runner#incremental_slot_rows}' `observed` and `derived`
651
+ # rows);
652
+ # - the CHAIN: the rows the last full run recorded for inputs a recheck does not re-derive
653
+ # ({Runner#baseline_dependency_rows}), and the plugin reads credited to each file's analysis. A full run
654
+ # starts a chain; a recheck takes the previous slot's, replaces the reads of every file it re-analysed with
655
+ # this run's, and drops the removed files'.
656
+ #
657
+ # A recheck writes only when the previous slot exists and was written against the very snapshot file it
658
+ # restored: otherwise some run in between — a pool run, `--no-cache`, a write that failed — analysed files
659
+ # without recording their reads, and the chain cannot vouch for them. It then writes nothing, and the next
660
+ # full run starts a fresh chain. Declining to write is always safe; it only leaves the next null run on the
661
+ # full path.
662
+ #
663
+ # Every row not recorded as a plugin read it must still describe the tree the run read
664
+ # ({IncrementalRunSlot::WriteGuard}): the analysed files' (whose session digest a full run takes after its
665
+ # analysis), the `derived` rows, and the chain's baseline, taken now or carried ({#guarded_rows}).
666
+ #
667
+ # @param written_identity — the snapshot file as this run leaves it, recorded for the next writer's check.
668
+ def write_run_slot(diagnostics, written_identity:)
669
+ return unless run_slot_writable?
670
+
671
+ roots = @paths || @configuration.paths
672
+ rows = @last_runner.incremental_slot_rows(roots: roots)
673
+ chain = rows && (@slot_carried_from ? carry_chain(rows, roots) : start_chain(rows))
674
+ analysed = chain && analysed_file_rows
675
+ return if analysed.nil?
676
+
677
+ baseline, pinned, reads = chain
678
+ return unless @slot_guard&.admits?(guarded_rows(analysed, rows.derived, baseline), pinned: pinned)
679
+
680
+ # A save the run read after the mark's tick must not hide behind an unmoved stat tuple.
681
+ reads = reads.transform_values { |descriptor| @slot_guard.mark_racy(descriptor) }
682
+
683
+ entry = IncrementalRunSlot::Entry.new(
684
+ diagnostics: diagnostics, roots: IncrementalRunSlot.as_written(roots),
685
+ baseline: baseline, pinned: pinned, reads: reads, snapshot: written_identity
686
+ )
687
+ dependencies = @slot_guard.mark_racy(run_slot_dependencies(analysed, rows, [baseline, pinned], reads.values))
688
+ wrote = IncrementalRunSlot.write(
689
+ store: @cache_store, configuration: @configuration, entry: entry, dependencies: dependencies,
690
+ target: IncrementalRunSlot::Target.new(files: @analyzed, roots: roots)
691
+ )
692
+ discard_previous_slot(roots) if wrote
693
+ rescue StandardError
694
+ nil
695
+ end
696
+
697
+ # The previous slot is keyed by the previous path set; once this run's stands, nothing asks for it again.
698
+ def discard_previous_slot(roots)
699
+ return if @slot_carried_from.nil? || @slot_carried_from.sort == @analyzed.sort
700
+
701
+ IncrementalRunSlot.discard(
702
+ store: @cache_store, configuration: @configuration,
703
+ target: IncrementalRunSlot::Target.new(files: @slot_carried_from, roots: roots)
704
+ )
705
+ end
706
+
707
+ # ADR-45 WD2 — the mark {IncrementalRunSlot::WriteGuard#admits?} checks the slot's rows against, taken before
708
+ # the run reads anything. nil (no slot this run) for a store no slot is written to, or no fingerprint.
709
+ def start_slot_guard(fingerprint)
710
+ return nil if fingerprint.nil? || @cache_store.nil? || @cache_store.read_only? || !@buffer.nil?
711
+ # Known before the run: none of these can be written afterwards, so the stamp and the recomputed
712
+ # fingerprint would be spent for nothing.
713
+ return nil unless @workers.zero? && !@configuration.effects_enabled?
714
+
715
+ IncrementalRunSlot::WriteGuard.start(
716
+ configuration: @configuration, roots: @paths || @configuration.paths, cache_root: @cache_store.root,
717
+ fingerprint: fingerprint
718
+ )
719
+ end
720
+
721
+ # The runs whose answer the slot may hold: a writable store, no editor buffer (whose bytes exist only in
722
+ # the editor), a sequential run (a pool worker's plugin reads never reach this process), effect collection
723
+ # off ({IncrementalRunSlot.serve} declines it), and no opaque plugin — the next full-path run would then be
724
+ # cold, not the warm run the probe stands in for.
725
+ def run_slot_writable?
726
+ !@cache_store.nil? && !@cache_store.read_only? && @buffer.nil? && @workers.zero? &&
727
+ !@configuration.effects_enabled? && @opaque_plugin_ids.empty? && !@last_runner.nil?
728
+ end
729
+
730
+ def start_chain(rows)
731
+ baseline = @last_runner.baseline_dependency_rows(files: @analyzed)
732
+ baseline && [baseline.owned, baseline.pinned, rows.by_file.slice(*@analyzed)]
733
+ end
734
+
735
+ def carry_chain(rows, roots)
736
+ return nil if @restored_snapshot_identity.nil?
737
+
738
+ previous = IncrementalRunSlot.previous_entry(
739
+ store: @cache_store, configuration: @configuration,
740
+ target: IncrementalRunSlot::Target.new(files: @slot_carried_from, roots: roots)
741
+ )
742
+ return nil if previous.nil? || previous.snapshot != @restored_snapshot_identity
743
+
744
+ served = previous.reads.except(*@last_runner.analyzed_files)
745
+ [previous.baseline, previous.pinned, served.merge(rows.by_file).slice(*@analyzed)]
746
+ end
747
+
748
+ # One `:stat` row per analysed file, from the entry this session recorded for it (`#pack_digest`), re-packed
749
+ # against the file's current stat when the bytes still hash to the recorded digest but the tuple moved (a
750
+ # `touch`, a checkout): the snapshot keeps the old tuple, and a row carrying it would cost every later probe
751
+ # a re-hash of the file. nil when any file has no usable entry (it could not be read): no row could say what
752
+ # its answer was computed from.
753
+ def analysed_file_rows
754
+ @analyzed.map do |path|
755
+ packed = Cache::FileDigest.refresh_stat(path, @digests[path])
756
+ return nil if packed.nil?
757
+
758
+ Cache::Descriptor::FileEntry.new(path: path, comparator: :stat, value: packed)
562
759
  end
563
760
  end
564
761
 
762
+ # @param chained — the chain's baseline descriptors, owned and pinned
763
+ def run_slot_dependencies(analysed, rows, chained, reads)
764
+ carried = chained + reads
765
+ Cache::Descriptor.new(
766
+ files: analysed + rows.derived.files + rows.observed.files + carried.flat_map(&:files),
767
+ globs: (rows.derived.globs + rows.observed.globs + carried.flat_map(&:globs)).uniq
768
+ )
769
+ end
770
+
771
+ # What the write guard asks about besides the pinned baseline: every row the key does not pin that was not
772
+ # recorded as a plugin read it. A recheck's carried baseline is among them. The run that took it vouched for
773
+ # the tree it saw, but this run reads those inputs again (a recheck with a non-empty closure rebuilds its
774
+ # environment from the signature tree), and a save it read that is reverted before the run ends leaves every
775
+ # carried row fresh while the answer came from the saved bytes.
776
+ def guarded_rows(analysed, derived, baseline)
777
+ Cache::Descriptor.new(files: analysed + derived.files + baseline.files, globs: derived.globs + baseline.globs)
778
+ end
779
+
780
+ # The snapshot file's `(size, mtime_ns, ctime_ns, inode)`: which write of it a run restored, or left behind.
781
+ # A rewrite renames a new file into place, so any write moves it. nil when there is no file to stat.
782
+ def snapshot_identity(snapshot)
783
+ path = snapshot.respond_to?(:path) ? snapshot.path : nil
784
+ return nil if path.nil?
785
+
786
+ stat = File.stat(path)
787
+ [stat.size, Cache::FileDigest.ns_of(stat.mtime), Cache::FileDigest.ns_of(stat.ctime), stat.ino]
788
+ rescue SystemCallError
789
+ nil
790
+ end
791
+
565
792
  # Adopt a persisted snapshot's per-file state as this session's baseline (the warm-start path).
566
793
  def restore(payload)
567
794
  @analyzed = payload.analyzed
@@ -723,13 +950,16 @@ module Rigor
723
950
  removed.each { |path| forget(path) }
724
951
  @analyzed = current
725
952
  # ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
726
- # re-walked, removed files dropped, added files built), so adopt the runner's current set wholesale.
727
- @seed_bundles = runner.seed_bundles
953
+ # re-walked, removed files dropped, added files built), so adopt the runner's current set wholesale,
954
+ # stamping the re-walked ones with their source-RBS digest (issue #1536).
955
+ whole_project = current.all? { |path| analyze_set.include?(path) }
956
+ @seed_bundles = source_rbs_gate.stamp(runner.seed_bundles, whole_project: whole_project)
728
957
  fresh_by_file = per_file(runner.per_file_diagnostics)
729
958
  analyze_set.each do |path|
730
959
  @cache[path] = fresh_by_file[path] || []
731
960
  @digests[path] = pack_digest(path)
732
961
  end
962
+ forget_unbound_digests
733
963
  absorb_dependency_graph(runner)
734
964
  # Issues #796 / #794 — the recheck's own snapshots already carry the replayed rows folded together
735
965
  # with anything its closure demanded for itself, so this is the same union the next run replays.
@@ -860,12 +1090,24 @@ module Rigor
860
1090
  changed + removed, @constant_decls, new_constant_decls
861
1091
  )
862
1092
  keys = appeared_methods.map { |symbol| negative_key_for(symbol) }
863
- keys.concat(moved_classes.map { |klass| "class:#{klass.split('::').last}" })
1093
+ keys.concat(class_move_keys(moved_classes))
864
1094
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
865
1095
  Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
866
1096
  global_write_reporters
867
1097
  end
868
1098
 
1099
+ # The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
1100
+ # `subclass_of:<last segment of the superclass>` for a `[class, superclass]` pair appearing or vanishing. Any
1101
+ # class declaration moving may move the set of classes whose superclass the tables do not know, which
1102
+ # `subclass_of:*` stands for.
1103
+ def class_move_keys(moved)
1104
+ names, edges = moved.partition { |entry| entry.is_a?(String) }
1105
+ keys = names.map { |klass| "class:#{klass.split('::').last}" }
1106
+ keys.concat(edges.map { |(_child, parent)| "subclass_of:#{parent.split('::').last}" })
1107
+ keys << "subclass_of:*" unless moved.empty?
1108
+ keys
1109
+ end
1110
+
869
1111
  # Issue #1120 — the consumers whose `call.undefined-method` answer read the refinement table for a method
870
1112
  # name whose refinements moved in this edit. The before-state is each file's seed bundle (the per-file
871
1113
  # table the last run recorded), the after-state the scan's per-file tables. A refine body is not a class's
@@ -888,6 +1130,7 @@ module Rigor
888
1130
  index[:class_sources].each do |class_name, files|
889
1131
  files.each { |file| result[file] << class_name }
890
1132
  end
1133
+ Incremental.add_superclass_edges(result, index[:class_sources], index[:superclasses])
891
1134
  result.transform_values(&:freeze).freeze
892
1135
  end
893
1136
 
@@ -1017,12 +1260,6 @@ module Rigor
1017
1260
  TOP_LEVEL_KEY = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
1018
1261
  private_constant :TOP_LEVEL_KEY
1019
1262
 
1020
- # B1 — plugin require-names that ingest COMMENT content as semantic input (inline-RBS reads `# @rbs` /
1021
- # `#:` annotations). B1's code fingerprint ignores comments, so a project configuring one of these opts
1022
- # OUT of the bundle-equality skip (a comment edit could change a cross-file type it contributes).
1023
- COMMENT_INGESTING_PLUGIN_IDS = %w[rigor-rbs-inline].freeze
1024
- private_constant :COMMENT_INGESTING_PLUGIN_IDS
1025
-
1026
1263
  def negative_key_for(symbol)
1027
1264
  class_name, method = symbol.split("#", 2)
1028
1265
  class_name == TOP_LEVEL_KEY ? "toplevel:#{method}" : "method:#{symbol}"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "runner"
5
+ require_relative "../cache/value_digest"
5
6
 
6
7
  module Rigor
7
8
  module Analysis
@@ -98,9 +99,9 @@ module Rigor
98
99
  def digest_registry(registry)
99
100
  return Result.new(digest: nil, opaque_plugin_ids: [].freeze) if registry.nil? || registry.empty?
100
101
 
101
- facts_by_plugin = facts_by_plugin(registry.plugins.first&.services&.fact_store)
102
- parts = fact_parts(facts_by_plugin)
103
102
  opaque = []
103
+ facts_by_plugin = facts_by_plugin(registry, opaque)
104
+ parts = fact_parts(facts_by_plugin)
104
105
  registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts_by_plugin, parts, opaque) }
105
106
  # Sort the parts so the digest is independent of plugin registration / iteration order.
106
107
  Result.new(
@@ -129,16 +130,25 @@ module Rigor
129
130
 
130
131
  # `{ plugin_id => ["name=digest", ...] }` for every ADR-9 published fact (channel a). A plugin that
131
132
  # published nothing does not appear.
132
- def facts_by_plugin(fact_store)
133
+ #
134
+ # A fact the fingerprint cannot digest is an input it cannot see, so its publisher is opaque, whether or
135
+ # not the publisher contributes a type itself: other plugins read its facts. A fact store that cannot be
136
+ # read at all makes every plugin opaque. Both force the full run; dropping the facts instead would let a
137
+ # plugin with another surface reuse a snapshot its facts no longer match.
138
+ def facts_by_plugin(registry, opaque)
133
139
  result = Hash.new { |hash, key| hash[key] = [] }
134
- return result if fact_store.nil?
135
-
136
- fact_store.each_fact do |fact|
137
- result[fact.plugin_id.to_s] << "#{fact.name}=#{digest_value(fact.value)}"
140
+ fact_store = registry.plugins.first&.services&.fact_store
141
+ fact_store&.each_fact do |fact|
142
+ plugin_id = fact.plugin_id.to_s
143
+ begin
144
+ result[plugin_id] << "#{fact.name}=#{digest_value(fact.value)}"
145
+ rescue StandardError
146
+ opaque << plugin_id
147
+ end
138
148
  end
139
149
  result
140
150
  rescue StandardError
141
- # An unreadable fact store contributes nothing; opacity for contributing plugins is decided below.
151
+ registry.plugins.each { |plugin| opaque << safe_id(plugin) }
142
152
  Hash.new { |hash, key| hash[key] = [] }
143
153
  end
144
154
 
@@ -175,12 +185,13 @@ module Rigor
175
185
  true
176
186
  end
177
187
 
178
- # A stable content digest of an arbitrary fact / producer value. Producer values are Marshal-clean by
179
- # contract (the ADR-45 disk cache serialises them the same way), so this is the same round-trip the cache
180
- # already relies on. A Marshal failure raises to the caller, which marks the plugin opaque (never a
181
- # silent wrong-value digest).
188
+ # A digest of a fact / producer / hook value that depends on the value alone (issue #1574). A producer's
189
+ # value is computed on one run and served from its ADR-45 cache entry on the next; the two are equal but
190
+ # do not share the same objects, so a digest of their `Marshal.dump` bytes differed and invalidated the
191
+ # snapshot on every such switch. {Cache::ValueDigest} reads no identity. A value it cannot digest raises
192
+ # to the caller, which marks the plugin opaque (never a silent wrong-value digest).
182
193
  def digest_value(value)
183
- Digest::SHA256.hexdigest(Marshal.dump(value))
194
+ Cache::ValueDigest.hexdigest(value)
184
195
  end
185
196
 
186
197
  def contributes_types?(plugin)