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
@@ -25,7 +25,8 @@ module Rigor
25
25
  # On-disk byte-layout version. Bumped on incompatible format changes (independent of
26
26
  # {Descriptor::SCHEMA_VERSION}, which covers the descriptor schema rather than the byte layout). v2
27
27
  # (ADR-54 WD2): the value payload is zlib-deflated on write and inflated on read — Marshal blobs
28
- # compress to 13–16 % at an inflate cost an order of magnitude below their `Marshal.load`. v1 entries
28
+ # compressed to 13–16 % at zlib's default level (now {DEFLATE_LEVEL}) at an inflate cost an order of
29
+ # magnitude below their `Marshal.load`. v1 entries
29
30
  # fail the header check and read as silent misses; the `schema_version.txt` marker additionally carries
30
31
  # this version, so the first writable run after a bump clears the root and reclaims the unreadable
31
32
  # bytes.
@@ -60,6 +61,14 @@ module Rigor
60
61
 
61
62
  STALE_TEMP_FILE_AGE_SECONDS = 60 * 60
62
63
 
64
+ # #1507 — the zlib level for everything written under the cache root: these value payloads (ADR-54 WD2),
65
+ # the incremental snapshot, and `rigor unused`'s scan bundle. The snapshot and the scan bundle are
66
+ # rewritten whole whenever a run changed anything, so their deflate is on the edit path. On Mastodon the
67
+ # default level took 122 ms for the snapshot (9.5 MB raw) and 131 ms for `rbs.environment` (22.8 MB raw);
68
+ # `BEST_SPEED` takes 38 ms and 49 ms, for a cache about a quarter larger. Inflate reads any level, so the
69
+ # level is not part of the format.
70
+ DEFLATE_LEVEL = Zlib::BEST_SPEED
71
+
63
72
  # Header literal: 5-byte ASCII magic, 1-byte separator, 1-byte format version.
64
73
  HEADER = "RIGOR\x00#{FORMAT_VERSION.chr}".b.freeze
65
74
 
@@ -241,8 +250,13 @@ module Rigor
241
250
  # validation always re-checks the filesystem — but a single run only looks up once.
242
251
  #
243
252
  # `generation_cap:` carries the same producer-declared compaction budget as {#fetch_or_compute}.
253
+ #
254
+ # `on_hit:` is called with the entry's stored dependency descriptor on a fresh hit, before the value
255
+ # is returned, and never on a miss. A caller whose own dependency record must carry a served value's
256
+ # inputs replays them from it (#1558, {Plugin::Base#cache_for}): a hit runs no block, so nothing else
257
+ # reports what the value was computed from.
244
258
  def fetch_or_validate(producer_id:, key_descriptor:, generation_cap:, params: {},
245
- serialize: nil, deserialize: nil)
259
+ serialize: nil, deserialize: nil, on_hit: nil)
246
260
  validate_producer_id!(producer_id)
247
261
  declare_generation_cap(producer_id, generation_cap)
248
262
  disk = ensure_schema_version!
@@ -252,6 +266,7 @@ module Rigor
252
266
  cached = path && read_entry(path, deserialize: deserialize)
253
267
  if (validated = fresh_pair_value(cached))
254
268
  @monitor.synchronize { record(:hits, producer_id) }
269
+ on_hit&.call(validated[1])
255
270
  return validated[0]
256
271
  end
257
272
 
@@ -285,6 +300,51 @@ module Rigor
285
300
  validated[0]
286
301
  end
287
302
 
303
+ # ADR-45 WD2 (#1507) — a record-and-validate entry's value WITHOUT validating its dependencies. The
304
+ # `--incremental` session reads the previous run-result slot to carry forward the rows a narrowed run
305
+ # cannot re-derive, and that slot is expected to be stale: the edit that started this run is in it. So
306
+ # the answer here is never served — only rows are taken from it, and they are validated again, with
307
+ # everything else, the next time the slot is read. nil on a miss, a malformed entry or an unavailable
308
+ # disk tier. Records neither a hit nor a miss, since nothing was looked up to be served.
309
+ def peek_unvalidated(producer_id:, key_descriptor:, params: {})
310
+ validate_producer_id!(producer_id)
311
+ return nil unless ensure_schema_version!
312
+
313
+ key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
314
+ pair = read_entry(entry_path(producer_id, key))&.value
315
+ return nil unless pair.is_a?(Array) && pair.size == 2 && pair[1].is_a?(Descriptor)
316
+
317
+ pair[0]
318
+ end
319
+
320
+ # ADR-45 WD2 (#1507) — the write half of {#fetch_or_validate} on its own, and unconditional. That method
321
+ # writes only after it failed to validate what is there; a writer that has just computed an answer of its
322
+ # own must store THAT answer even when the entry already on disk still validates, because it is the one
323
+ # the run printed. Same entry format and the same failure contract as {#fetch_or_validate}'s write: a
324
+ # disk-side failure is swallowed, a value `Marshal` cannot dump raises. Returns whether it wrote.
325
+ def store_validated(producer_id:, key_descriptor:, generation_cap:, value:, dependencies:, params: {})
326
+ validate_producer_id!(producer_id)
327
+ declare_generation_cap(producer_id, generation_cap)
328
+ return false unless ensure_schema_version!
329
+
330
+ key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
331
+ wrote = try_write_entry(entry_path(producer_id, key), key_descriptor, [value, dependencies])
332
+ @monitor.synchronize { record(:writes, producer_id) } if wrote
333
+ wrote
334
+ end
335
+
336
+ # ADR-45 WD2 (#1507) — removes one entry, for a writer that has just superseded it under another key and
337
+ # knows nothing will ask for it again. Best-effort like every other unlink here; a no-op on a read-only
338
+ # store.
339
+ def discard(producer_id:, key_descriptor:, params: {})
340
+ validate_producer_id!(producer_id)
341
+ return if @read_only || !ensure_schema_version!
342
+
343
+ path = entry_path(producer_id, key_descriptor.cache_key_for(producer_id: producer_id, params: params))
344
+ unlink_entry_and_shard?(path) if File.file?(path)
345
+ nil
346
+ end
347
+
288
348
  # ADR-6 § "Eviction" — compaction pass over the on-disk cache. No-op when the store is read-only.
289
349
  #
290
350
  # The generation cap of pass 2 comes from what the producers themselves declared through this Store's
@@ -469,7 +529,7 @@ module Rigor
469
529
  FileUtils.mkdir_p(File.dirname(path))
470
530
 
471
531
  descriptor_bytes = descriptor.to_canonical_bytes
472
- value_bytes = Zlib::Deflate.deflate(serialize_value(value, serialize))
532
+ value_bytes = Zlib::Deflate.deflate(serialize_value(value, serialize), DEFLATE_LEVEL)
473
533
 
474
534
  body = +"".b
475
535
  body << HEADER
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Rigor
6
+ module Cache
7
+ # Issue #1574 — a digest of a Ruby VALUE, not of its object graph. The same value digests to the same hex
8
+ # string whether a producer computed it in this process or a cache entry served it from disk, whatever
9
+ # Strings, Arrays or Hashes happen to share one object.
10
+ #
11
+ # `Marshal.dump` is not that function. It writes an object reached twice once and back-references it after,
12
+ # and a Marshal round trip does not keep that sharing: `Marshal.load` rebuilds a String unfrozen, and
13
+ # `Hash#[]=` then stores a frozen copy of it as the key rather than the String itself. A frozen String used
14
+ # both as a row's field and as the key of an index Hash (rigor-sidekiq's `worker_index`, rigor-rails-i18n's
15
+ # `locale_index`) therefore dumps to one set of bytes when computed and another when served, and a digest of
16
+ # those bytes flipped the ADR-88 fact-surface fingerprint on every run that switched between the two.
17
+ #
18
+ # The encoding below walks the value as a tree. Each node is written as a type tag plus its content, and
19
+ # sizes are length-prefixed, so the encoding is unambiguous and two values encode alike only when they are
20
+ # alike. Nothing in it reads identity or frozenness. An object (a Data, a Struct, or any other non-core
21
+ # object) is always written as its class name and the SHA-256 of its own encoding. That digest is taken once
22
+ # per object and reused wherever the object is reached again, so a row shared between an index's list and
23
+ # its by-name Hash is walked once, and the encoding is the same whether an object is shared or not.
24
+ #
25
+ # What it keeps, so a changed value still changes the digest:
26
+ #
27
+ # - every class distinction Marshal keeps: Integer vs Float, String vs Symbol, a subclass of String / Array /
28
+ # Hash / Set vs the base class, and the class name of every Data, Struct and plain object;
29
+ # - Hash and Set iteration ORDER. Two Hashes equal under `==` but built in another order digest differently.
30
+ # A consumer may iterate a fact in order (the first matching row wins), and Marshal keeps the order, so the
31
+ # computed and served forms agree on it; digesting the order is the direction that can only over-invalidate;
32
+ # - a Hash's `default` value, the `compare_by_identity` flag of a Hash or Set, a Range's `exclude_end?`, a
33
+ # Regexp's options, and a non-ASCII String's encoding. ASCII-only Strings digest alike in every encoding, as
34
+ # `==` has it.
35
+ #
36
+ # What it leaves out, although Marshal keeps it:
37
+ #
38
+ # - instance variables set on a String, Array, Hash or Set, which `==` ignores too;
39
+ # - modules an object was `extend`ed with (Marshal refuses singleton methods, and this does not look);
40
+ # - the hidden fields a core exception subclass keeps beside its message, backtrace and cause
41
+ # (`NameError#name`, `SystemCallError#errno`), which reach the digest only through the message, and the
42
+ # hidden fields any other C-implemented class keeps outside `instance_variables`.
43
+ #
44
+ # A plain object digests as its instance variables, sorted by name; a Data or Struct as its members and its
45
+ # instance variables; an Exception as its message, backtrace, cause and instance variables. Each is read
46
+ # through the core class's own method, so a subclass that overrides `to_h`, `each_pair`, `message` or
47
+ # `backtrace` cannot hide a field. An object that defines `marshal_dump` or `_dump` digests as what that
48
+ # returns, which is what Marshal keeps of it.
49
+ #
50
+ # A value that cannot be digested this way raises {Uncanonicalisable}: an object whose state lives outside
51
+ # its instance variables (a Proc, an IO, a Mutex: what `Marshal.dump` refuses), a Hash with a default proc, an
52
+ # anonymous class, or a structure nested more than {MAX_DEPTH} deep, which is what a cycle becomes. A caller
53
+ # must treat that as "unknown", never as "unchanged".
54
+ module ValueDigest
55
+ # The value has no canonical encoding. Fail closed: treat the value as moved.
56
+ class Uncanonicalisable < StandardError; end
57
+
58
+ # Containers nested deeper than this are refused. No fact is this deep; a cyclic structure is infinitely
59
+ # deep, so this is also the cycle check, and it fires well before the Ruby stack would.
60
+ MAX_DEPTH = 256
61
+
62
+ module_function
63
+
64
+ # The hex SHA-256 of `value`'s canonical encoding.
65
+ def hexdigest(value)
66
+ Encoder.new.hexdigest(value)
67
+ rescue SystemStackError
68
+ raise Uncanonicalisable, "#{value.class} is nested too deeply to digest"
69
+ end
70
+
71
+ # One walk of one value. The canonical bytes are fed to the SHA-256 in chunks, so a multi-megabyte fact
72
+ # never has its whole encoding in memory at once.
73
+ class Encoder
74
+ FLUSH_AT = 64 * 1024
75
+ private_constant :FLUSH_AT
76
+
77
+ def initialize
78
+ @sha = Digest::SHA256.new
79
+ @out = String.new(capacity: FLUSH_AT * 2, encoding: Encoding::BINARY)
80
+ @depth = 0
81
+ @objects = {}.compare_by_identity # object => the SHA-256 of its encoding
82
+ @symbols = {} # Symbol => its encoding
83
+ @plain = {}.compare_by_identity # class => whether Marshal writes its instances as their ivars
84
+ # The core readers, taken unbound so an override on the value's class cannot change what is read. Held
85
+ # per walk rather than in constants, which must stay Ractor-shareable (#1064).
86
+ @data_to_h = Data.instance_method(:to_h)
87
+ @struct_each_pair = Struct.instance_method(:each_pair)
88
+ @exception_readers = %i[to_s backtrace cause].map { |name| Exception.instance_method(name) }
89
+ @instance_variables = Kernel.instance_method(:instance_variables)
90
+ @instance_variable_get = Kernel.instance_method(:instance_variable_get)
91
+ end
92
+
93
+ def hexdigest(value)
94
+ write(value)
95
+ @sha.update(@out)
96
+ @sha.hexdigest
97
+ end
98
+
99
+ private
100
+
101
+ def write(value)
102
+ case value
103
+ when String then write_string(value)
104
+ when Symbol then @out.append_as_bytes(@symbols[value] ||= encoded("y", value.name))
105
+ when Hash then enter { write_hash(value) }
106
+ when Array then enter { write_array(value) }
107
+ when Set then enter { write_set(value) }
108
+ when Integer then @out.append_as_bytes("i", value.to_s, ";")
109
+ when nil then @out.append_as_bytes("n")
110
+ when true then @out.append_as_bytes("t")
111
+ when false then @out.append_as_bytes("f")
112
+ else write_other(value)
113
+ end
114
+ end
115
+
116
+ def write_other(value)
117
+ case value
118
+ when Float then @out.append_as_bytes("d", value.to_s, ";")
119
+ when Range then enter { write_range(value) }
120
+ when Regexp then write_regexp(value)
121
+ when Rational then @out.append_as_bytes("q", value.numerator.to_s, "/", value.denominator.to_s, ";")
122
+ when Complex then enter { write_pair("c", value.real, value.imaginary) }
123
+ when Module then write_bytes(value.is_a?(Class) ? "k" : "m", class_name(value))
124
+ else write_object(value)
125
+ end
126
+ end
127
+
128
+ # The common case, an ASCII-only plain String, is written inline: it is most of the nodes of a large fact.
129
+ def write_string(value)
130
+ if value.ascii_only? && value.instance_of?(String)
131
+ @out.append_as_bytes("s:", value.bytesize.to_s, ":", value)
132
+ else
133
+ write_subclass(value, String)
134
+ write_bytes("s", value)
135
+ end
136
+ flush if @out.bytesize >= FLUSH_AT
137
+ end
138
+
139
+ # A tag and a String's bytes, length-prefixed. The encoding is written only for a String with non-ASCII
140
+ # bytes: ASCII-only Strings compare equal whatever their encoding, and one built from a Symbol is
141
+ # US-ASCII.
142
+ def write_bytes(tag, string)
143
+ @out.append_as_bytes(encoded(tag, string))
144
+ end
145
+
146
+ def encoded(tag, string)
147
+ encoding = string.ascii_only? ? "" : string.encoding.name
148
+ "#{tag}#{encoding}:#{string.bytesize}:".b.append_as_bytes(string)
149
+ end
150
+
151
+ def write_hash(value)
152
+ raise Uncanonicalisable, "a Hash with a default proc" if value.default_proc
153
+
154
+ write_subclass(value, Hash)
155
+ default = value.default
156
+ @out.append_as_bytes("h", value.compare_by_identity? ? "I" : "", default.nil? ? "" : "D",
157
+ value.size.to_s, ":")
158
+ write(default) unless default.nil?
159
+ value.each_pair do |key, entry|
160
+ write(key)
161
+ write(entry)
162
+ end
163
+ end
164
+
165
+ def write_array(value)
166
+ write_subclass(value, Array)
167
+ @out.append_as_bytes("a", value.size.to_s, ":")
168
+ value.each { |element| write(element) }
169
+ end
170
+
171
+ def write_set(value)
172
+ write_subclass(value, Set)
173
+ @out.append_as_bytes("e", value.compare_by_identity? ? "I" : "", value.size.to_s, ":")
174
+ value.each { |element| write(element) }
175
+ end
176
+
177
+ def write_range(value)
178
+ write_pair(value.exclude_end? ? "rx" : "ri", value.begin, value.end)
179
+ end
180
+
181
+ def write_pair(tag, first, second)
182
+ @out.append_as_bytes(tag)
183
+ write(first)
184
+ write(second)
185
+ end
186
+
187
+ def write_regexp(value)
188
+ @out.append_as_bytes("g", value.options.to_s, ";")
189
+ write_bytes("s", value.source)
190
+ end
191
+
192
+ # Every object that is not a core value: its class name and the digest of its content, taken once per
193
+ # object however often the value reaches it.
194
+ def write_object(value)
195
+ digest = @objects[value] ||= enter { object_digest(value) }
196
+ write_bytes("o", class_name(value.class))
197
+ @out.append_as_bytes(digest)
198
+ end
199
+
200
+ # The object's content, walked into a SHA-256 of its own. A `marshal_dump` / `_dump` hook decides what
201
+ # Marshal keeps of an object, so it decides the digest too; otherwise the object is its members (a Data or
202
+ # Struct) or its message, backtrace and cause (an Exception), then its instance variables.
203
+ def object_digest(value)
204
+ outer_sha = @sha
205
+ outer_out = @out
206
+ @sha = Digest::SHA256.new
207
+ @out = +"".b
208
+ write_content(value)
209
+ @sha.update(@out)
210
+ @sha.digest
211
+ ensure
212
+ @sha = outer_sha
213
+ @out = outer_out
214
+ end
215
+
216
+ def write_content(value)
217
+ if value.respond_to?(:marshal_dump, true)
218
+ @out.append_as_bytes("U")
219
+ write(value.send(:marshal_dump))
220
+ elsif value.is_a?(Time)
221
+ # `Time#_dump` keeps the offset and zone as instance variables on the String it returns, which the
222
+ # String encoding leaves out.
223
+ @out.append_as_bytes("z")
224
+ [value.to_r, value.utc_offset, value.zone].each { |part| write(part) }
225
+ elsif value.respond_to?(:_dump, true)
226
+ @out.append_as_bytes("u")
227
+ write(value.send(:_dump, -1))
228
+ else
229
+ write_members(value)
230
+ end
231
+ end
232
+
233
+ def write_members(value)
234
+ case value
235
+ when Data then write_fields("D", @data_to_h.bind_call(value))
236
+ when Struct then write_fields("T", struct_members(value))
237
+ when Exception then write_exception(value)
238
+ else
239
+ assert_plain(value)
240
+ @out.append_as_bytes("V")
241
+ end
242
+ write_fields("@", instance_variables(value))
243
+ end
244
+
245
+ def struct_members(value)
246
+ members = []
247
+ @struct_each_pair.bind_call(value) { |name, field| members << [name, field] }
248
+ members
249
+ end
250
+
251
+ # Marshal keeps an exception's message, backtrace and cause in hidden instance variables, which
252
+ # `instance_variables` does not list: two exceptions that differ only in their message would otherwise
253
+ # digest alike. A plugin that records parse errors as exceptions depends on the message moving the digest.
254
+ def write_exception(value)
255
+ @out.append_as_bytes("X")
256
+ @exception_readers.each { |reader| write(reader.bind_call(value)) }
257
+ end
258
+
259
+ def instance_variables(value)
260
+ @instance_variables.bind_call(value).sort.map { |name| [name, @instance_variable_get.bind_call(value, name)] }
261
+ end
262
+
263
+ def write_fields(tag, pairs)
264
+ fields = pairs.to_a
265
+ @out.append_as_bytes(tag, fields.size.to_s, ":")
266
+ fields.each do |name, field|
267
+ write(name.to_sym)
268
+ write(field)
269
+ end
270
+ end
271
+
272
+ # An object's instance variables are all of its state only when Marshal writes it that way. `Marshal.dump`
273
+ # refuses a Proc, an IO, a Mutex or a Thread (C-level state and no `_dump_data`), and so does this. The
274
+ # depth limit of 1 makes the check cost one object: Marshal decides whether it can write the object
275
+ # before it writes any instance variable, and raises `ArgumentError` on reaching the first one. It is
276
+ # asked once per class, since whether a class keeps state outside Ruby does not vary by instance.
277
+ #
278
+ # The `ArgumentError` cannot tell a listed instance variable from a hidden one that a C-implemented class
279
+ # keeps, so an Exception, whose message and backtrace are hidden, is read explicitly above instead.
280
+ def assert_plain(value)
281
+ klass = value.class
282
+ plain = @plain.fetch(klass) do
283
+ @plain[klass] = begin
284
+ Marshal.dump(value, 1)
285
+ true
286
+ rescue ArgumentError # "exceed depth limit": the object itself was written
287
+ true
288
+ rescue TypeError
289
+ false
290
+ end
291
+ end
292
+ raise Uncanonicalisable, "#{klass} keeps its state outside its instance variables" unless plain
293
+ end
294
+
295
+ # A subclass of a core value keeps its class through Marshal, so the class is part of the value.
296
+ def write_subclass(value, base)
297
+ klass = value.class
298
+ write_bytes("C", class_name(klass)) unless klass.equal?(base)
299
+ end
300
+
301
+ def class_name(klass)
302
+ klass.name || raise(Uncanonicalisable, "an anonymous #{klass.class.name.downcase} has no name to digest")
303
+ end
304
+
305
+ def enter
306
+ raise Uncanonicalisable, "a value nested deeper than #{MAX_DEPTH} (a cycle?)" if @depth >= MAX_DEPTH
307
+
308
+ @depth += 1
309
+ begin
310
+ result = yield
311
+ ensure
312
+ @depth -= 1
313
+ end
314
+ flush if @out.bytesize >= FLUSH_AT
315
+ result
316
+ end
317
+
318
+ def flush
319
+ @sha.update(@out)
320
+ @out.clear
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
@@ -100,7 +100,12 @@ module Rigor
100
100
  # in `#annotate` raise `invalid byte sequence in US-ASCII`. Ruby source is UTF-8 by convention (the parser's own
101
101
  # assumption absent a magic comment).
102
102
  source = File.read(file, mode: "r:bom|utf-8")
103
+ # Prism parses the ORIGINAL bytes, so a `# -*- coding: -*-` file is read in its own encoding. Only the text the
104
+ # line-wise regexps below see is scrubbed: a byte invalid in UTF-8 (a comment, `__END__` data, a non-UTF-8
105
+ # file) would make them raise.
106
+ # Scrubbing is byte-for-byte (`?` per bad byte), so Prism's positions still address it.
103
107
  parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
108
+ source = source.scrub { |bytes| "?" * bytes.bytesize } unless source.valid_encoding?
104
109
  return 1 if parse_errors?(parse_result, file)
105
110
 
106
111
  # `converged_loop_recording` re-records fixpoint-tracked loop bodies from their converged (post-writeback)
@@ -126,8 +126,10 @@ module Rigor
126
126
 
127
127
  def collect_diagnostics(configuration, _options)
128
128
  cache_store = Cache::Store.new(root: configuration.cache_path)
129
- # IMPORTANT: do NOT activate the existing baseline when generating a fresh one — otherwise the new file records
130
- # the post-filter (silenced) diagnostic set, which is empty after a successful first run.
129
+ # The generating run is the loaded configuration with `baseline:` off (#1777): every other key shapes the
130
+ # diagnostic set `rigor check` reports, so it must shape the recorded one too. The baseline filter itself is
131
+ # applied CLI-side by `check` (`CheckCommand#apply_baseline_filter`), never by the runner; clearing it here is
132
+ # defensive.
131
133
  configuration_for_generation = override_configuration_baseline_off(configuration)
132
134
  runner = Analysis::Runner.new(
133
135
  configuration: configuration_for_generation,
@@ -137,23 +139,10 @@ module Rigor
137
139
  runner.run(configuration_for_generation.paths).diagnostics
138
140
  end
139
141
 
142
+ # The loaded configuration with only `baseline` disabled (#1777): any other key can change the
143
+ # diagnostic set, and the baseline must record the set `rigor check` will then report.
140
144
  def override_configuration_baseline_off(configuration)
141
- # Synthesise a new Configuration with `baseline` explicitly disabled. The original Configuration is frozen-ish
142
- # so we round-trip through the constructor with an override hash.
143
- defaults = Configuration::DEFAULTS.merge(
144
- "paths" => configuration.paths,
145
- "exclude" => configuration.exclude_patterns,
146
- "plugins" => configuration.plugins,
147
- "disable" => configuration.disabled_rules,
148
- "libraries" => configuration.libraries,
149
- "signature_paths" => configuration.signature_paths,
150
- "pre_eval" => configuration.pre_eval,
151
- "severity_profile" => configuration.severity_profile.to_s,
152
- "severity_overrides" => configuration.severity_overrides,
153
- "baseline" => false,
154
- "cache" => { "path" => configuration.cache_path }
155
- )
156
- Configuration.new(defaults)
145
+ configuration.without_baseline
157
146
  end
158
147
 
159
148
  # ---- dump --------------------------------------------------
@@ -59,8 +59,8 @@ module Rigor
59
59
  # ADR-87 WD4 — try to serve the whole run from the ADR-45 cache before booting the engine. A hit boots
60
60
  # only CLI + config + cache + digest code (no `rigor/inference`), skipping the plugin prepass + env
61
61
  # build entirely.
62
- probed = try_run_cache_hit(configuration, options, buffer, cache_root)
63
- return finalize_cache_hit(probed, configuration, options, config_warnings) unless probed.nil?
62
+ served = serve_from_run_cache(configuration, options, buffer, cache_root, config_warnings)
63
+ return served unless served.nil?
64
64
 
65
65
  load_check_dependencies
66
66
  special = dispatch_special_check_mode(configuration, options, cache_root, buffer)
@@ -113,6 +113,16 @@ module Rigor
113
113
  false
114
114
  end
115
115
 
116
+ # The exit code of a run served whole from a run-result slot, engine-free, or nil to run the engine: the
117
+ # plain slot for an ordinary check (ADR-87 WD4), the incremental session's for `--incremental` (ADR-45 WD2).
118
+ def serve_from_run_cache(configuration, options, buffer, cache_root, config_warnings)
119
+ probed = try_run_cache_hit(configuration, options, buffer, cache_root)
120
+ return finalize_cache_hit(probed, configuration, options, config_warnings) unless probed.nil?
121
+
122
+ hit = try_incremental_run_hit(configuration, options, buffer, cache_root)
123
+ hit && finalize_incremental_hit(hit, configuration, options)
124
+ end
125
+
116
126
  # ADR-87 WD4 — attempt the boot-slimming run-cache hit. Returns the cached {Analysis::Result} (severity
117
127
  # profile applied, no stats — matching a cache-served `Runner#run`) on a hit, or nil to fall through to
118
128
  # the full engine path. Only an ordinary sequential check whose result IS cache-served the same way is
@@ -141,8 +151,9 @@ module Rigor
141
151
 
142
152
  # Any signal that per-file analysis would run in a worker pool (CLI `--workers`, the env override, or the
143
153
  # config default). Deliberately over-declines (a false positive only forgoes the fast lane); mirrors
144
- # {CheckRunnerFactory.resolve_workers} without requiring it (that pulls in the engine).
145
- def pool_workers_configured?(options, configuration)
154
+ # {CheckRunnerFactory.resolve_workers} without requiring it (that pulls in the engine). `unreadable` is the
155
+ # answer for a count that does not parse, which the full path's resolver raises on.
156
+ def pool_workers_configured?(options, configuration, unreadable: false)
146
157
  cli = options[:workers]
147
158
  return Integer(cli).positive? if cli
148
159
 
@@ -151,7 +162,41 @@ module Rigor
151
162
 
152
163
  configuration.parallel_workers.positive?
153
164
  rescue ArgumentError
154
- false
165
+ unreadable
166
+ end
167
+
168
+ # ADR-45 WD2 (#1507) — `rigor check --incremental`'s engine-free null run: serve the run-result slot the
169
+ # incremental session wrote after its last run, when every row of it still validates. Returns an
170
+ # {Analysis::IncrementalRunSlot::Hit}, or nil to run the full incremental path.
171
+ def try_incremental_run_hit(configuration, options, buffer, cache_root)
172
+ return nil unless incremental_run_hit_eligible?(configuration, options, buffer)
173
+
174
+ require_relative "../analysis/incremental_run_slot"
175
+ Analysis::IncrementalRunSlot.serve(
176
+ configuration: configuration, cache_root: cache_root, paths: @argv.empty? ? configuration.paths : @argv
177
+ )
178
+ end
179
+
180
+ # A plain `--incremental` run the session would answer as a warm recheck and write a slot for. Every other
181
+ # shape declines, and the full path handles it exactly as before: an editor buffer (never saved), `--no-cache`
182
+ # (no store to hold a slot), `--verify-incremental`, a worker pool (the session writes no slot for one: a
183
+ # worker's plugin reads never reach the process that records them), and `--explain`, which the session
184
+ # ignores today (#1533) — the slot is keyed without it, so the probe declines rather than answer for a flag
185
+ # its writer never saw. The rest mirror the plain probe: `--coverage`, `--cache-stats` and the
186
+ # `RIGOR_*_TRACE` probes each want the full path's own output.
187
+ def incremental_run_hit_eligible?(configuration, options, buffer)
188
+ options.fetch(:incremental) && buffer.nil? &&
189
+ !options.fetch(:no_cache) && !options.fetch(:explain) && !options.fetch(:coverage) &&
190
+ !options.fetch(:cache_stats) && !options.fetch(:verify_incremental) &&
191
+ !pool_workers_configured?(options, configuration, unreadable: true) &&
192
+ ENV["RIGOR_BUDGET_TRACE"].to_s.empty? && ENV["RIGOR_HEAP_TRACE"].to_s.empty?
193
+ end
194
+
195
+ # The tail {#run_incremental_check} gives a warm run, from the slot's answer: the same banner, baseline
196
+ # filter, output and exit code. No fact-surface note, since a warm run never has one.
197
+ def finalize_incremental_hit(hit, configuration, options)
198
+ report_incremental_run(true, hit.file_count)
199
+ write_incremental_result(hit.result.diagnostics, configuration, options)
155
200
  end
156
201
 
157
202
  # ADR-87 WD4 — the engine-free tail for a cache-served hit: baseline filter + output + exit code,
@@ -237,9 +282,15 @@ module Rigor
237
282
  def run_incremental_check(configuration, options, cache_root, buffer = nil)
238
283
  require_relative "check_runner_factory"
239
284
  paths = @argv.empty? ? nil : @argv
240
- fingerprint = Cache::IncrementalSnapshot.fingerprint(
241
- configuration: configuration, roots: paths || configuration.paths
242
- )
285
+ # `--no-cache` is a cold run that touches no persistent state: a nil fingerprint is the session's "uncomputable
286
+ # inputs" signal, so it neither loads the snapshot, nor saves it, nor writes the run-result slot.
287
+ fingerprint = if options.fetch(:no_cache)
288
+ nil
289
+ else
290
+ Cache::IncrementalSnapshot.fingerprint(
291
+ configuration: configuration, roots: paths || configuration.paths
292
+ )
293
+ end
243
294
  snapshot = Cache::IncrementalSnapshot.new(root: cache_root)
244
295
  store = incremental_cache_store(configuration, options, cache_root)
245
296
  session = Analysis::IncrementalSession.new(
@@ -258,12 +309,23 @@ module Rigor
258
309
  # The banner's file count comes from the session's analyzed set (cold analyses all; a warm recheck's
259
310
  # `@analyzed` advances to the current file set), so a dedicated probe Runner + `Dir.glob` is no longer
260
311
  # built just to size the banner (recon §3 — the analysis tree was expanded three times per recheck).
261
- @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
262
- "(#{session.analyzed_files.size} files)")
312
+ report_incremental_run(warm, session.analyzed_files.size)
263
313
  emit_incremental_fact_surface_notes(session)
264
314
  write_incremental_cache_stats(session, cache_root, store, options.fetch(:format)) if
265
315
  options.fetch(:cache_stats)
266
316
 
317
+ write_incremental_result(diagnostics, configuration, options)
318
+ end
319
+
320
+ def report_incremental_run(warm, file_count)
321
+ @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
322
+ "(#{file_count} files)")
323
+ end
324
+
325
+ # The incremental tail, shared by the full path and the ADR-45 WD2 slot hit so a served run prints exactly
326
+ # what the full path would. It is not the ordinary check's tail — no `config_warnings` block in the JSON,
327
+ # no CI-detected annotations, no `--baseline-strict` verdict — which is why the hit cannot reuse that one.
328
+ def write_incremental_result(diagnostics, configuration, options)
267
329
  result = apply_baseline_filter(Analysis::Result.new(diagnostics: diagnostics, stats: nil), configuration,
268
330
  options)
269
331
  write_result(result, options.fetch(:format), fail_on: options.fetch(:fail_on))
@@ -282,8 +344,13 @@ module Rigor
282
344
  def run_editor_mode_option_b(session, snapshot, fingerprint, configuration, options)
283
345
  result = session.run_buffer_recheck(snapshot: snapshot, fingerprint: fingerprint)
284
346
  if result.nil?
285
- @err.puts("rigor: --incremental has no reusable snapshot for this project; analysing the buffer " \
286
- "alone (run `rigor check --incremental` once to enable whole-project editor mode).")
347
+ if options.fetch(:no_cache)
348
+ @err.puts("rigor: --no-cache skips the incremental snapshot, so the buffer is analysed on its own " \
349
+ "(no whole-project editor mode).")
350
+ else
351
+ @err.puts("rigor: --incremental has no reusable snapshot for this project; analysing the buffer " \
352
+ "alone (run `rigor check --incremental` once to enable whole-project editor mode).")
353
+ end
287
354
  return nil
288
355
  end
289
356
 
@@ -523,7 +590,10 @@ module Rigor
523
590
  options[:coverage] = true
524
591
  end
525
592
  opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
526
- opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
593
+ opts.on("--no-cache",
594
+ "Disable the persistent cache for this run (with --incremental: no snapshot is read or written)") do
595
+ options[:no_cache] = true
596
+ end
527
597
  opts.on("--[no-]stats",
528
598
  "Print run summary (files, classes, memory, wall time) to stderr (default: on)") do |value|
529
599
  options[:stats] = value
@@ -615,9 +685,10 @@ module Rigor
615
685
  # ADR-103 WD15 — captured before `DEFAULTS.merge` below folds "no `effects:` key" and "`effects:
616
686
  # false`" into the same value; see `Configuration.load`'s identical capture.
617
687
  effects_key_present = data.key?("effects")
688
+ target_ruby_key_present = data.key?("target_ruby")
618
689
  data = data.dup
619
690
  data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
620
- Configuration.new(Configuration::DEFAULTS.merge(data), effects_key_present)
691
+ Configuration.new(Configuration::DEFAULTS.merge(data), effects_key_present, target_ruby_key_present)
621
692
  end
622
693
 
623
694
  # ADR-50 § WD2 — applies the `--bleeding-edge[=ids]` / `--no-bleeding-edge` CLI selection over the configured
@@ -246,7 +246,7 @@ module Rigor
246
246
  def real_or_expanded(path)
247
247
  File.realpath(path)
248
248
  rescue StandardError
249
- File.expand_path(path)
249
+ File.absolute_path(path)
250
250
  end
251
251
 
252
252
  # ADR-25 carry-over — the plain `bundle install` layout. {Environment::BundleSigDiscovery} detects a