rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -7,7 +7,9 @@ module Rigor
7
7
  # Refined types for predefined Ruby / stdlib constants whose upstream RBS signatures are
8
8
  # broader than the constants' documented runtime invariants.
9
9
  #
10
- # Resolution is two-tiered:
10
+ # Resolution is two-tiered, and **both tiers are closed tables authored in this file**. A name
11
+ # taken from the analysed program is never resolved against the analyzer's own object space —
12
+ # see *Why the table is closed* below.
11
13
  #
12
14
  # **Tier 1 — exact-value whitelist** (`FOLDED_CONSTANTS`):
13
15
  # Constants whose value is bit-for-bit identical across every Ruby version and platform
@@ -23,24 +25,44 @@ module Rigor
23
25
  # and as plain `Integer`s they fall through Tier 2 to the RBS type harmlessly.
24
26
  # `Complex::I` is deferred (no complex-fold consumer).
25
27
  #
26
- # **Tier 2 — runtime String inspection**:
27
- # For any other constant, the module resolves it via `const_get` against the analyzer's
28
- # own Ruby runtime. Core / stdlib constants (e.g. `RUBY_VERSION`, `RUBY_PLATFORM`) are
29
- # always loaded into the analyzer process; project-defined constants are not (they live
30
- # only in ASTs), so their `const_get` raises `NameError` and the lookup falls through to
31
- # the RBS type tier.
28
+ # **Tier 2 — String refinements for a closed set of interpreter constants**
29
+ # (`RUNTIME_STRING_CONSTANTS`): each listed name is read ONCE, at load time, from the analyzer's
30
+ # own runtime and classified by the value found:
32
31
  #
33
- # For a successfully resolved `String` value:
34
- # - empty string → no refinement (fall through to RBS `String`)
32
+ # - not a String, or an empty one → no entry (the name falls through to the RBS type)
35
33
  # - a Ruby numeric literal → `numeric-string`
36
34
  # - non-empty otherwise → `non-empty-string`
37
35
  #
38
- # **Exclusion set** (`RUNTIME_INSPECTION_EXCLUDED`):
39
- # String constants that appear non-empty in the current runtime but are documented to be
40
- # potentially empty in some build configuration or alternative implementation. Exclusions
41
- # are populated by scanning Ruby's C source (version.c, etc.) and RBS comments for any
42
- # constant whose documentation says "may be empty" or "platform-specific default". None
43
- # are known today; the set exists as a safety net.
36
+ # Admission criterion for the list: the constant's value is fixed by the same interpreter choice
37
+ # that already fixes the core / stdlib RBS the engine reads, and it is non-empty in every build.
38
+ # A third-party gem's constant does NOT qualify the analysed project resolves its own copy
39
+ # through its `Gemfile.lock`, which this layer cannot see. This is the criterion
40
+ # `Inference::VersionGuard::VERSION_CONSTANTS` already applies to the constants it folds guards on.
41
+ #
42
+ # **Why the table is closed** ([#680](https://github.com/rigortype/rigor/issues/680)):
43
+ # Tier 2 used to resolve ANY name reaching it — a name read out of the analysed source — with
44
+ # `const_get` against the analyzer's runtime, guarded by `const_defined?(part, false)`. That
45
+ # guard does not answer the question it was written for: `const_defined?` is true for a
46
+ # **registered but not yet triggered autoload**, so `const_get` fired the autoload and the target
47
+ # file was *executed* inside the analyzer. Analysing CRuby's own `lib/prism` reached
48
+ # `prism/translation/ruby_parser.rb`, which calls `exit` at the top level when `sexp_processor`
49
+ # is absent; `SystemExit` is not a `StandardError`, so neither the rescue in this file nor the
50
+ # runner's per-file rescue saw it and the run stopped with no diagnostics and no summary. The
51
+ # target of an autoload is arbitrary — it may exit, raise, print, mutate global state, or take
52
+ # unbounded time — and `rigor check` is routinely pointed at code the user did not write.
53
+ #
54
+ # Closing the table rather than only guarding the walk is what makes that structural: no name
55
+ # from the analysed program reaches `const_get` at all. A census of what the open walk actually
56
+ # bought says the precision cost is nil — over GitLab's `app` + `lib` (11,189 files, 184,965
57
+ # constant references) it answered for five names and ten references (`RUBY_VERSION`,
58
+ # `RUBY_PLATFORM`, `RUBY_DESCRIPTION`, `File::SEPARATOR`, `File::PATH_SEPARATOR`, every one of
59
+ # them listed below), against 17 references that would have autoloaded `ipaddr`; over Mastodon,
60
+ # three names and five references against 43 such. What it answered for *beyond* the list was
61
+ # always a constant that happened to live in **Rigor's own** bundle — `Rigor::VERSION` and
62
+ # `RBS::VERSION` while Rigor checks itself, rubygems and Bundler internals while it checks
63
+ # CRuby's `lib/` — and that is worse than no answer: identical source would type differently
64
+ # depending on how the analyzer was installed. Removing an ad hoc `Object.const_get` from an
65
+ # analysis path is the same move ADR-4 § 330 already made for predicate narrowing.
44
66
  #
45
67
  # This module is consulted by `Environment#constant_for_name` BEFORE the RBS
46
68
  # constant-type table (widest types) but AFTER in-source constant writes (the user's own
@@ -68,70 +90,97 @@ module Rigor
68
90
 
69
91
  # --- tier 2 -------------------------------------------------------
70
92
 
71
- # String constants whose runtime value is non-empty in the current Ruby but that should
72
- # NOT be narrowed because they are documented to be potentially empty in some build or
73
- # implementation.
93
+ # The closed set of names tier 2 may read, by the admission criterion in the module note.
74
94
  #
75
- # Methodology: grep Ruby's version.c and similar C sources, and the RBS comment corpus,
76
- # for any constant annotated with "may be empty" or "platform-specific default". Add
77
- # the full qualified path (without leading "::") when a genuine risk is found.
78
- RUNTIME_INSPECTION_EXCLUDED = Set[].freeze
79
- private_constant :RUNTIME_INSPECTION_EXCLUDED
95
+ # The `RUBY_*` / `Ruby::*` pairs and `Encoding::UNICODE_VERSION` are defined by the interpreter
96
+ # itself; `File::SEPARATOR` / `Separator` / `PATH_SEPARATOR` / `ALT_SEPARATOR` by its `File`
97
+ # core class (`Separator` is the capital-S alias of `SEPARATOR`, declared in core RBS and just
98
+ # as spellable in project code; `ALT_SEPARATOR` is `nil` off Windows and simply gets no entry
99
+ # there); `Gem::VERSION` by rubygems, which is not the interpreter's to version but IS loaded
100
+ # in every process that can run `rigor` at all. A name that resolves to something other than a
101
+ # non-empty String on the running interpreter is skipped, so listing one costs nothing where it
102
+ # does not exist.
103
+ RUNTIME_STRING_CONSTANTS = %w[
104
+ RUBY_VERSION RUBY_RELEASE_DATE RUBY_PLATFORM RUBY_DESCRIPTION RUBY_COPYRIGHT
105
+ RUBY_ENGINE RUBY_ENGINE_VERSION RUBY_REVISION
106
+ Ruby::VERSION Ruby::RELEASE_DATE Ruby::PLATFORM Ruby::DESCRIPTION Ruby::COPYRIGHT
107
+ Ruby::ENGINE Ruby::ENGINE_VERSION Ruby::REVISION
108
+ File::SEPARATOR File::Separator File::PATH_SEPARATOR File::ALT_SEPARATOR
109
+ Encoding::UNICODE_VERSION
110
+ Gem::VERSION
111
+ ].freeze
112
+ private_constant :RUNTIME_STRING_CONSTANTS
80
113
 
81
114
  NON_EMPTY_STRING = Type::Combinator.non_empty_string.freeze
82
115
  NUMERIC_STRING = Type::Combinator.numeric_string.freeze
83
116
  private_constant :NON_EMPTY_STRING, :NUMERIC_STRING
84
117
 
85
- # --- public API ---------------------------------------------------
86
-
87
- # @param name [String] unqualified constant name (e.g. `"Math::PI"`,
88
- # `"RUBY_VERSION"`, `"Ruby::ENGINE"`)
89
- # @return [Rigor::Type, nil] refined type, or nil to fall through
90
- def self.lookup(name)
91
- FOLDED_CONSTANTS[name] || inspect_runtime_string(name)
92
- end
93
-
94
118
  # --- private ------------------------------------------------------
95
119
 
96
- # Resolves `name` via `const_get` in the analyzer's runtime and returns a refined
97
- # String carrier, or nil.
98
- def self.inspect_runtime_string(name)
99
- return nil if RUNTIME_INSPECTION_EXCLUDED.include?(name)
120
+ # @param value [String] a non-empty string
121
+ # @return [Rigor::Type]
122
+ def self.classify_string(value)
123
+ if Type::Refined.ruby_numeric_literal?(value)
124
+ NUMERIC_STRING
125
+ else
126
+ NON_EMPTY_STRING
127
+ end
128
+ end
129
+ private_class_method :classify_string
100
130
 
131
+ # Reads `name` in the analyzer's runtime, returning its value when that is a non-empty String.
132
+ #
133
+ # Called ONLY with the names in {RUNTIME_STRING_CONSTANTS} — Rigor's own source — and only
134
+ # while this file is being loaded. It is never handed a name from the analysed program.
135
+ #
136
+ # @param name [String] a qualified constant path without a leading "::"
137
+ # @return [String, nil]
138
+ def self.runtime_string_value(name)
101
139
  mod = ::Object
102
140
  name.split("::").each do |part|
103
- # Resolve only constants already present — never let analysing a reference drive
104
- # the analyzer's own runtime to autoload or run a `const_missing` hook. A
105
- # `Digest::UUID` reference in project code otherwise makes `const_get` trigger
106
- # `Digest.const_missing` → `require "digest/uuid"`, and a missing optional library
107
- # raises `LoadError` (a `ScriptError`, not the `NameError` the const_get walk
108
- # expects), which would abort the whole run rather than fall through to the RBS
109
- # tier. `const_defined?(part, false)` answers the same "is this resolvable here"
110
- # question without the side effect — a project-defined constant (the common case)
111
- # is simply absent and returns nil, no exception raised.
112
141
  return nil unless mod.is_a?(::Module) && mod.const_defined?(part, false)
142
+ # `const_defined?` is true for a REGISTERED-BUT-NOT-YET-TRIGGERED autoload, so on its own
143
+ # it does not mean "already in memory" and `const_get` would EXECUTE the target file. Only
144
+ # `Module#autoload?` separates the two: it returns the registered path while the autoload
145
+ # is pending and nil once it has run, so declining on it declines exactly the dangerous
146
+ # case and still resolves a constant genuinely loaded ([#680]).
147
+ return nil if mod.autoload?(part)
113
148
 
114
149
  mod = mod.const_get(part, false)
115
150
  end
116
151
 
117
- return nil unless mod.is_a?(::String) && !mod.empty?
118
-
119
- classify_string(mod)
120
- rescue ::NameError, ::TypeError, ::LoadError
152
+ mod.is_a?(::String) && !mod.empty? ? mod : nil
153
+ rescue ::StandardError, ::ScriptError, ::SystemExit
154
+ # Deliberately wider than the `NameError` / `TypeError` / `LoadError` this used to name.
155
+ # Refining a constant is an optimisation and nothing it can hit justifies stopping the
156
+ # process; `SystemExit` is the one that actually did ([#680]) and is not a `StandardError`,
157
+ # and `ScriptError` covers the `LoadError` family for the same reason. `Interrupt`,
158
+ # `SignalException` and `NoMemoryError` are deliberately NOT caught — a Ctrl-C must keep
159
+ # reaching the user, and an out-of-memory process is not one to keep analysing in.
121
160
  nil
122
161
  end
123
- private_class_method :inspect_runtime_string
162
+ private_class_method :runtime_string_value
163
+
164
+ # @return [Hash{String => Rigor::Type}] frozen, built once at load time.
165
+ def self.build_runtime_string_refinements
166
+ RUNTIME_STRING_CONSTANTS.each_with_object({}) do |name, table|
167
+ value = runtime_string_value(name)
168
+ table[name] = classify_string(value) if value
169
+ end.freeze
170
+ end
171
+ private_class_method :build_runtime_string_refinements
124
172
 
125
- # @param value [String] a non-empty string
126
- # @return [Rigor::Type]
127
- def self.classify_string(value)
128
- if Type::Refined.ruby_numeric_literal?(value)
129
- NUMERIC_STRING
130
- else
131
- NON_EMPTY_STRING
132
- end
173
+ RUNTIME_STRING_REFINEMENTS = build_runtime_string_refinements
174
+ private_constant :RUNTIME_STRING_REFINEMENTS
175
+
176
+ # --- public API ---------------------------------------------------
177
+
178
+ # @param name [String] unqualified constant name (e.g. `"Math::PI"`,
179
+ # `"RUBY_VERSION"`, `"Ruby::ENGINE"`)
180
+ # @return [Rigor::Type, nil] refined type, or nil to fall through
181
+ def self.lookup(name)
182
+ FOLDED_CONSTANTS[name] || RUNTIME_STRING_REFINEMENTS[name]
133
183
  end
134
- private_class_method :classify_string
135
184
  end
136
185
  end
137
186
  end
@@ -66,13 +66,32 @@ module Rigor
66
66
  FILE_NON_EMPTY = ->(_arg_types) { NON_EMPTY_STRING }
67
67
  private_constant :FILE_NON_EMPTY
68
68
 
69
+ STRING_NOMINAL = Type::Combinator.nominal_of("String").freeze
70
+ private_constant :STRING_NOMINAL
71
+
72
+ # `IO.read(name)` / `File.read(name)` return nil ONLY when a `length` argument is given
73
+ # and the stream is at EOF; the no-length form always returns a String (it raises on
74
+ # error). Upstream RBS declares one row with an optional length — `-> String?` for every
75
+ # call shape — so the whole-file idiom `File.read(path, mode: 'rb')` read as nilable and
76
+ # fired `possible-nil-receiver` on working code (redmine's Import#read_file_head, three
77
+ # sites). The refinement drops the nil exactly when no positional length is present; a
78
+ # keyword-only trailing hash (`mode:`, `encoding:`) is not a length. Same family as the
79
+ # staged upstream signature fixes (#159).
80
+ IO_READ_NO_LENGTH = lambda do |arg_types|
81
+ positional = arg_types.grep_v(Type::HashShape)
82
+ positional.size <= 1 ? STRING_NOMINAL : nil
83
+ end
84
+ private_constant :IO_READ_NO_LENGTH
85
+
69
86
  # Frozen ((owner_class_name, method_name, kind) => handler) table. The kind tag is
70
87
  # `:both`, `:singleton`, or `:instance`. New entries SHOULD prefer `:both` unless the
71
88
  # singleton- and instance-side shapes genuinely differ.
72
89
  OVERRIDES = {
73
90
  ["Kernel", :__dir__, :both] => KERNEL_DIR,
74
91
  ["File", :expand_path, :singleton] => FILE_NON_EMPTY,
75
- ["File", :dirname, :singleton] => FILE_NON_EMPTY
92
+ ["File", :dirname, :singleton] => FILE_NON_EMPTY,
93
+ ["IO", :read, :singleton] => IO_READ_NO_LENGTH,
94
+ ["File", :read, :singleton] => IO_READ_NO_LENGTH
76
95
  }.freeze
77
96
  private_constant :OVERRIDES
78
97
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ module Rigor
6
+ module Cache
7
+ # The `RBS::AST::Annotation` location carry (issue #799): the two halves of the `marshal_dump` /
8
+ # `marshal_load` pair {file:lib/rigor/cache/rbs_environment_marshal_patch.rb the env-cache Marshal patch}
9
+ # installs on `RBS::AST::Annotation`, plus the buffer a reconstructed location points at. That file's
10
+ # header carries the why; this one is the how.
11
+ #
12
+ # Nothing here reconstructs the annotation's SOURCE — the cached env has no file contents, and a
13
+ # consumer that wants the text already has `Annotation#string`. What it reconstructs is the pair of
14
+ # `(line, column)` answers `RBS::Location` gives, so a diagnostic positioned on the directive reads the
15
+ # same line warm as it does cold.
16
+ module AnnotationLocation
17
+ # The two positions {Buffer} distinguishes. `RBS::Location` addresses its buffer by character
18
+ # offset, and the reconstruction has no content to offset into, so the offsets are used as tags:
19
+ # 0 is "the start pair", 1 is "the end pair".
20
+ START_POS = 0
21
+ END_POS = 1
22
+
23
+ # A content-less `RBS::Buffer` that answers `pos_to_loc` from the pairs the cold parse had.
24
+ #
25
+ # Answering there is all it takes: `RBS::Location#start_line` and its three siblings are C methods
26
+ # that resolve the offset through `buffer.pos_to_loc`, so the reconstructed Location reports the real
27
+ # position without the buffer having to hold (or synthesize) the file it came from. The alternative —
28
+ # a synthetic content string of `line - 1` newlines — is exact only while an annotation stays on one
29
+ # line, and `%a{...}` may span two.
30
+ class Buffer < ::RBS::Buffer
31
+ def initialize(name:, start_loc:, end_loc:)
32
+ super(name: name, content: "")
33
+ @start_loc = start_loc
34
+ @end_loc = end_loc
35
+ end
36
+
37
+ def pos_to_loc(pos)
38
+ pos == START_POS ? @start_loc : @end_loc
39
+ end
40
+ end
41
+
42
+ module_function
43
+
44
+ # @param location [RBS::Location, nil]
45
+ # @return [Array, nil] `[name, start_line, start_column, end_line, end_column]`, or nil when there is
46
+ # no location to carry. Fail-soft: a location whose buffer cannot answer is dumped as nil rather
47
+ # than failing the whole environment's dump.
48
+ def dump(location)
49
+ return nil if location.nil?
50
+
51
+ buffer = location.buffer
52
+ name = buffer.respond_to?(:name) ? buffer.name.to_s : nil
53
+ [name, location.start_line, location.start_column, location.end_line, location.end_column]
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ # @param payload [Array, nil] what {dump} produced.
59
+ # @return [RBS::Location, nil]
60
+ def load(payload)
61
+ return nil if payload.nil?
62
+
63
+ name, start_line, start_column, end_line, end_column = payload
64
+ name = ::RBS::Location::CACHED_BUFFER_NAME if name.nil? || name.empty?
65
+ buffer = Buffer.new(name: name, start_loc: [start_line, start_column], end_loc: [end_line, end_column])
66
+ ::RBS::Location.new(buffer: buffer, start_pos: START_POS, end_pos: END_POS)
67
+ rescue StandardError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -38,7 +38,17 @@ module Rigor
38
38
  # whose RBS dangles an interface or type-alias reference gets stubs where an older Rigor discarded the
39
39
  # whole batch and cached an env in which those signatures are inert. Same reasoning as v3: the
40
40
  # marshalled env is the cached value, so it MUST be rebuilt for the fix to take effect.
41
- SCHEMA_VERSION = 6
41
+ # v7: #526 — the def-index seed bundles gain the `:extends` table (`extend M` / `extend self` /
42
+ # bare `module_function`), which `finalize_def_index` folds into the singleton-side method tables. A
43
+ # pre-7 bundle would silently contribute no extends for an unchanged file, so cached bundles must
44
+ # read as misses once and rebuild carrying the slot.
45
+ # v8: #577 / ADR-45 WD1 — a plugin {Plugin::IoBoundary} read that finds its path MISSING now records
46
+ # an absence row ({FileEntry.absent}: `:exists` / `"false"`) into the run-result and plugin-producer
47
+ # dependency descriptors, so a file that appears after a run which probed for it (a `db/schema.rb`
48
+ # added to a schema-less project) invalidates the warm entry. A pre-8 entry carries no absence rows
49
+ # and would validate fresh across exactly that edit, so cached entries must read as misses once and
50
+ # rebuild carrying the rows.
51
+ SCHEMA_VERSION = 8
42
52
 
43
53
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
44
54
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -54,6 +64,11 @@ module Rigor
54
64
  # `:digest`, while the {Runner} run-dependency descriptor and plugin {IoBoundary} reads use `:stat`.
55
65
  VALID_COMPARATORS = %i[digest stat mtime exists].freeze
56
66
 
67
+ # The two `:exists` values — `File.exist?`'s answer rendered with `to_s`, which is what
68
+ # {Descriptor#file_entry_fresh?} compares the live answer against.
69
+ PRESENT = "true"
70
+ ABSENT = "false"
71
+
57
72
  attr_reader :path, :comparator, :value
58
73
 
59
74
  value_fields :path, :comparator, :value
@@ -80,6 +95,30 @@ module Rigor
80
95
  new(path: path, comparator: :stat, value: packed)
81
96
  end
82
97
 
98
+ # ADR-45 WD1 (#577) — the absence row: fresh while `path` does not exist, stale the moment anything
99
+ # (a file, a directory, a symlink) comes into existence there. {Plugin::IoBoundary#read_file}
100
+ # records one when a read fails because the path is missing, so a value computed on "X is absent"
101
+ # invalidates when X appears. Validation is a single `File.exist?` — no stat tuple, no digest,
102
+ # nothing that can move on an unchanged tree — so an absence row never costs a warm run its hit.
103
+ def self.absent(path:)
104
+ new(path: path, comparator: :exists, value: ABSENT)
105
+ end
106
+
107
+ # ADR-45 WD1b (#613) — {.absent}'s positive twin: fresh while `path` exists, stale once it is gone.
108
+ # {Plugin::IoBoundary#file?} / `#directory?` record one when an existence probe found what it asked
109
+ # for and the plugin never read the bytes (a discovery root it globbed, a config file whose mere
110
+ # presence switched a mode). It is the weakest file row — a rewrite in place leaves it fresh — so it
111
+ # is only ever the row for a dependency that really is on existence alone; a read records the `:stat`
112
+ # row over it.
113
+ def self.present(path:)
114
+ new(path: path, comparator: :exists, value: PRESENT)
115
+ end
116
+
117
+ # @return [Boolean] whether this row records the path as absent (see {.absent}).
118
+ def absent?
119
+ comparator == :exists && value == ABSENT
120
+ end
121
+
83
122
  def to_h
84
123
  { "path" => path, "comparator" => comparator.to_s, "value" => value }
85
124
  end
@@ -280,7 +319,8 @@ module Rigor
280
319
  # version) belong in the cache *key*, not the validated dependency set — so a descriptor carrying any
281
320
  # non-file slot is never considered fresh (it was built wrong for this use). ADR-60 WD3 adds `globs`
282
321
  # alongside `files` as a re-validatable slot: a {GlobEntry} is fresh when re-globbing + re-digesting
283
- # reproduces its recorded value.
322
+ # reproduces its recorded value. ADR-45 WD1 (#577): an absence row ({FileEntry.absent}) is fresh
323
+ # while its path is still missing — the negative half of the dependency set.
284
324
  def fresh?
285
325
  return false unless gems.empty? && plugins.empty? && configs.empty? && dependencies.empty?
286
326
 
@@ -54,8 +54,37 @@ module Rigor
54
54
  # loads as nil (a clean cold rebuild — no migration). 12: ADR-103 WD13 / issue #382 adds the effects
55
55
  # sidecar — `effect_collections` (the per-file {Rigor::Effects::FileCollection}s a collecting run
56
56
  # produced) and the `effects_identity` they were produced under; a pre-12 blob mismatches the SCHEMA
57
- # gate and loads as nil (a clean cold rebuild — no migration).
58
- SCHEMA = 12
57
+ # gate and loads as nil (a clean cold rebuild — no migration). 13: issue #644 adds `constant_decls`
58
+ # (the per-file constant PUBLICATION CENSUS — `{qualified name => [literal] | :unpublishable}` — whose
59
+ # diff drives the new `constant:` edge's producer) and grows each seed bundle's row grammar by its own
60
+ # `constant_writes` census; a pre-13 blob mismatches the SCHEMA gate and loads as nil (a clean cold
61
+ # rebuild — no migration). 14: issue #707 widens each seed-bundle def row from `[node_id, name,
62
+ # fingerprint]` to `[node_id, name, fingerprint, nesting]`, carrying issue #681's recorded
63
+ # `Module.nesting` so a bundle-served callee resolves its constants under the declaration that owns it
64
+ # instead of a peel of the receiver's name; a pre-14 blob mismatches the SCHEMA gate and loads as nil (a
65
+ # clean cold rebuild — no migration). The bump is what keeps a pre-14 blob REJECTED rather than MISREAD:
66
+ # a three-element row destructures with `nesting` nil, which is indistinguishable from a top-level def,
67
+ # so every unchanged file would silently keep the pre-fix peel. 15: issue #682 gives each seed bundle a
68
+ # `header_nestings` table — the `Module.nesting` each class / module DECLARATION HEADER is written in —
69
+ # so a bundle-served class resolves its superclass and include names in the cref Ruby resolves them in
70
+ # rather than by peeling its own qualified name; a pre-15 blob mismatches the SCHEMA gate and loads as
71
+ # nil (a clean cold rebuild — no migration). Absence here is gradual rather than misread (the resolver
72
+ # falls back to the peel), but a warm run that peels where a cold run does not is exactly the
73
+ # `--verify-incremental` divergence 14 was bumped for.
74
+ # 16: issue #722 residue 1 gives each seed bundle's `superclasses` values a ROOTED MARKER — a leading
75
+ # `::` on an ancestor name the site wrote rooted (`class X < ::Base`, `Class.new(::Parent)`) — so the
76
+ # resolvers anchor it at the top level instead of walking the enclosing nesting. The value stays a
77
+ # String, so a pre-16 blob deserialises cleanly and is exactly why the gate has to reject it: an
78
+ # un-rooted value is indistinguishable from "not rooted", and every unchanged file would keep the
79
+ # pre-fix answer on a warm run while a cold run gave the right one.
80
+ # 17: issue #716 changes what a seed-bundle def row's `nesting` MEANS without changing its shape. A
81
+ # top-level def now records the EMPTY chain (Ruby's `Module.nesting` there is `[]`), so the resolver
82
+ # anchors its constants at the top level instead of peeling the caller's namespace; `nil` narrows to
83
+ # "no chain recorded at all". A pre-17 blob stored `nil` for exactly the top-level defs that now store
84
+ # `[]`, deserialises cleanly, and would send every unchanged file's top-level helper back to the peel
85
+ # on a warm run while a cold run resolved at the top level — the `--verify-incremental` divergence 14
86
+ # was bumped for, in the same table.
87
+ SCHEMA = 17
59
88
 
60
89
  # The persisted per-file state.
61
90
  # `cache` maps an analyzed file to its diagnostics.
@@ -69,6 +98,12 @@ module Rigor
69
98
  # ADR-46 slice 3:
70
99
  # `missing` maps a consumer to Set<"kind:name"> it looked up and missed.
71
100
  # `class_decls` maps a path to Set<qualified class name> it declares.
101
+ # Issue #644:
102
+ # `constant_decls` maps a path to that file's constant PUBLICATION CENSUS,
103
+ # `{qualified name => [literal] | :unpublishable}`. The descriptor is what the recheck diffs: a name's
104
+ # published answer is a function of the whole project's write set for it, so a value edit, a write
105
+ # becoming unpublishable, or a second declarer appearing / going away all move the answer without
106
+ # moving the name set.
72
107
  # ADR-85 WD2:
73
108
  # `seed_bundles` maps an analyzed path to its per-file discovery contribution (plain-data tables +
74
109
  # `(node_id, name, fingerprint)` def-node handles + content digest), so a warm recheck rebuilds the
@@ -103,7 +138,7 @@ module Rigor
103
138
  # and a vocabulary / catalogue / `effects:` change must invalidate the summaries alone.
104
139
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
105
140
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
106
- :missing, :class_decls, :seed_bundles, :plugin_fact_digest,
141
+ :missing, :class_decls, :constant_decls, :seed_bundles, :plugin_fact_digest,
107
142
  :return_summaries, :param_table,
108
143
  :effect_collections, :effects_identity)
109
144
 
@@ -218,6 +253,7 @@ module Rigor
218
253
  symbol_fingerprints: data[:symbol_fingerprints] || {},
219
254
  missing: data[:missing] || {},
220
255
  class_decls: data[:class_decls] || {},
256
+ constant_decls: data[:constant_decls] || {},
221
257
  seed_bundles: data[:seed_bundles] || {},
222
258
  plugin_fact_digest: data[:plugin_fact_digest],
223
259
  return_summaries: data[:return_summaries] || {},
@@ -241,6 +277,7 @@ module Rigor
241
277
  symbol_fingerprints: payload.symbol_fingerprints,
242
278
  missing: payload.missing,
243
279
  class_decls: payload.class_decls,
280
+ constant_decls: payload.constant_decls,
244
281
  seed_bundles: payload.seed_bundles,
245
282
  plugin_fact_digest: payload.plugin_fact_digest,
246
283
  return_summaries: payload.return_summaries,
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "rbs"
4
4
 
5
+ require_relative "annotation_location"
6
+
5
7
  # Adds `_dump` / `_load` to {RBS::Location} so an `RBS::Environment` (and its transitive AST nodes, all of
6
8
  # which carry Locations) round-trips through `Marshal`. The rbs gem's C-extension `RBS::Location` ships
7
9
  # without the Marshal hooks; until rbs grows them upstream this patch is the minimal monkey-patch the v0.0.9
@@ -9,11 +11,47 @@ require "rbs"
9
11
  #
10
12
  # Patch policy (purely additive):
11
13
  #
12
- # - `_dump` returns an empty string. The cached env loses per-node source-position info, but Rigor does not
13
- # consult `RBS::Location` from any analysis code path (every diagnostic uses Prism's own location), so the
14
- # loss is inert in practice.
15
- # - `_load` reconstructs a sentinel Location backed by an empty `<cached>` Buffer. Code paths that DID consult
16
- # Location after a cache hit see a benign zero-range value rather than crashing.
14
+ # - `_dump` returns the buffer's NAME and nothing else. Per-node source POSITIONS are still dropped (Rigor
15
+ # does not consult them from any analysis path every diagnostic uses Prism's own location), but the
16
+ # file a declaration came from is cheap to keep and is not inert: `rbs.coverage.definition-build-failed`
17
+ # names the conflicting signature files, and dropping the name made a warm run omit that clause while a
18
+ # cold run printed it, so the same project said different things by cache state (issue #696 review,
19
+ # second pass). It costs cache SIZE: a `_dump` payload is raw bytes, not a linkable object graph, so the
20
+ # path is written once per Location rather than once per buffer. Memoising the coerced name per buffer was
21
+ # tried and changes nothing for exactly that reason. Measured +6.6% on a realistic project (Rigor's own
22
+ # 349-file `lib`: 4,332K to 4,616K) and +12.3% on a one-file project (1,672K to 1,876K), both stable
23
+ # across reps — the env blob is fixed overhead, so the ratio falls as a project's own cached data grows
24
+ # and the realistic figure is the lower one. That is the price of a diagnostic that reads the same warm
25
+ # and cold; positions, which are far more numerous and which nothing reads, stay dropped.
26
+ # - `_load` reconstructs a zero-range Location over that name, falling back to a `<cached>` sentinel when
27
+ # the dump carried none. Code paths that DID consult Location after a cache hit see a benign value rather
28
+ # than crashing, and one that reads `buffer.name` now sees the real path.
29
+ # - Both directions were exercised rather than assumed. A NEW blob read by an OLDER `_load` — which ignored
30
+ # its argument — loads and behaves exactly as before; that direction is clean. An OLD blob read by THIS
31
+ # `_load` also loads without raising, and the sentinel fallback keeps it from ever naming `<cached>` as a
32
+ # path — but the conflicting-files clause then goes missing, so a warm run off a pre-change blob says less
33
+ # than a cold run does. ADR-6's store never evicts, so that would persist. `Cache::Store::FORMAT_VERSION`
34
+ # is therefore bumped to 3: `PAYLOAD_ABI_VERSION` already rebuilds across a release, and the bump closes
35
+ # the same-version window too.
36
+ # - `RBS::AST::Annotation` carries its own `marshal_dump` / `marshal_load`, which keep the annotation's
37
+ # POSITION as well as its file (issue #799). "Nothing reads positions" was never quite true: the two
38
+ # `conforms-to` rows — `rbs_extended.unsatisfied-conformance` and `dynamic.rbs-extended.unresolved` —
39
+ # are reported AT the directive, because there is no Ruby `def` a missing interface method could be
40
+ # reported at, so both read `start_line` / `start_column` off an annotation's location. (They are the
41
+ # only readers: {Rigor::RbsExtended::EnvelopeScanner} positions `effect.unknown-label` at an annotation
42
+ # too, but reaches it by parsing the project's own `.rbs` rather than the built env — refusing this very
43
+ # loss is one of the two reasons it does.) Cold the row is `sig/buffer.rbs:6:1`; through a `_dump`ed
44
+ # location it collapsed to `1:1`, which made `--verify-incremental` fail outright on any project carrying
45
+ # an unsatisfied directive (the replica normalises rows by position, and only one side of the comparison
46
+ # runs against the cached env) and made a warm `--incremental` run move a row on a tree that had not
47
+ # changed. The carry is scoped to annotations rather than done in `_dump` for the reason the paragraph
48
+ # above gives: a Location hangs off every AST node, and carrying its position measured +2.9% on Rigor's
49
+ # own env (9,941K to 10,229K) on top of the names. An annotation is rare by comparison — 18 of them in
50
+ # that same env — so the same carry costs +1.4K there, which is the whole argument for scoping it here
51
+ # rather than widening `_dump`. `Cache::Store::FORMAT_VERSION` is bumped to 4 for the same reason it was
52
+ # bumped to 3: a pre-change blob still loads (Marshal encodes an ivar dump and a `marshal_dump` payload
53
+ # differently, and only the latter reaches `marshal_load`), so without the bump a stale blob would keep
54
+ # reporting the moved position indefinitely.
17
55
  #
18
56
  # Idempotent: the guard checks `method_defined?(:_dump)` so requiring this file twice (or against an upstream
19
57
  # rbs that adds Marshal hooks itself) is a no-op.
@@ -33,13 +71,37 @@ require "rbs"
33
71
  # reference. Both classes are value objects fully described by `to_s`, so the round-trip is lossless.
34
72
  module RBS
35
73
  class Location
74
+ # The name a Location gets back when the dump carried none — an old blob, or a buffer that never had
75
+ # one. Never a real path, so a consumer that reports file names must filter it
76
+ # ({Rigor::Environment::RbsLoader::CACHED_LOCATION_BUFFER_NAME}).
77
+ CACHED_BUFFER_NAME = "<cached>"
78
+
36
79
  unless method_defined?(:_dump)
37
80
  def _dump(_)
38
- ""
81
+ buffer&.name.to_s
82
+ end
83
+
84
+ def self._load(name)
85
+ name = CACHED_BUFFER_NAME if name.nil? || name.empty?
86
+ new(buffer: ::RBS::Buffer.new(name: name, content: ""), start_pos: 0, end_pos: 0)
39
87
  end
88
+ end
89
+ end
90
+
91
+ module AST
92
+ class Annotation
93
+ # Carries the annotation's POSITION across the cache, not just its file. See the header's fourth
94
+ # bullet for why this one AST node opts out of the position-dropping `RBS::Location#_dump` above.
95
+ unless method_defined?(:marshal_dump)
96
+ def marshal_dump
97
+ [string, Rigor::Cache::AnnotationLocation.dump(location)]
98
+ end
40
99
 
41
- def self._load(_)
42
- new(buffer: ::RBS::Buffer.new(name: "<cached>", content: ""), start_pos: 0, end_pos: 0)
100
+ def marshal_load(payload)
101
+ dumped_string, dumped_location = payload
102
+ @string = dumped_string
103
+ @location = Rigor::Cache::AnnotationLocation.load(dumped_location)
104
+ end
43
105
  end
44
106
  end
45
107
  end
@@ -29,7 +29,23 @@ module Rigor
29
29
  # fail the header check and read as silent misses; the `schema_version.txt` marker additionally carries
30
30
  # this version, so the first writable run after a bump clears the root and reclaims the unreadable
31
31
  # bytes.
32
- FORMAT_VERSION = 2
32
+ #
33
+ # v3 (issue #696 review, second pass): `RBS::Location#_dump` now carries the buffer NAME rather than an
34
+ # empty string, so an env blob written before this change reconstructs every location behind the
35
+ # `<cached>` sentinel. That degrades gracefully — the sentinel is filtered, never printed as a path —
36
+ # but `rbs.coverage.definition-build-failed` then omits its conflicting-files clause, so a warm run off
37
+ # a pre-change blob says less than a cold run does, indefinitely: ADR-6's store never evicts.
38
+ # `PAYLOAD_ABI_VERSION` already rebuilds across a RELEASE, so the exposure is a same-version tree; this
39
+ # closes that window too, because "the same project reports differently depending on how you ran it" is
40
+ # the defect the diagnostic exists to end and a stale blob reintroduces it.
41
+ #
42
+ # v4 (issue #799): `RBS::AST::Annotation` now carries its own `marshal_dump` / `marshal_load`, which
43
+ # keep the annotation's POSITION as well as its file, so `rbs_extended.unsatisfied-conformance` points
44
+ # at the `%a{rigor:v1:conforms-to …}` line warm as well as cold. A pre-change blob loads perfectly well
45
+ # — Marshal encodes an ivar dump and a `marshal_dump` payload differently, and only the latter reaches
46
+ # `marshal_load` — which is exactly the problem: without a bump it would keep reporting the row at
47
+ # `1:1` forever, and `--verify-incremental` would keep failing on that project.
48
+ FORMAT_VERSION = 4
33
49
 
34
50
  # Payload ABI version. Store values are mostly Marshal blobs of Rigor/RBS objects, so a Rigor release
35
51
  # upgrade is an ABI boundary even when the byte layout and descriptor schema are unchanged. Folding the