rigortype 0.2.8 → 0.3.0

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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -24,8 +24,22 @@ module Rigor
24
24
  # Bump when the on-disk shape changes so stale snapshots are ignored rather than mis-deserialized. 5:
25
25
  # the blob is zlib-deflated (ADR-54 WD2 parity with `Store` entries — the snapshot is the one cache
26
26
  # artefact that does not go through `Store`); a raw pre-5 blob fails the inflate and loads as nil, the
27
- # usual fault-tolerant cold-run path.
28
- SCHEMA = 5
27
+ # usual fault-tolerant cold-run path. 6: adds the ADR-85 WD2 `seed_bundles` section (per-file discovery
28
+ # contributions with `(node_id, name, fingerprint)` def-node handles); a pre-6 blob mismatches the
29
+ # SCHEMA gate and loads as nil (a clean cold rebuild — no migration). 7: the seed bundle gains a
30
+ # `singleton_def_sources` table (ADR-46 slice 4 extended to class/singleton methods) AND `digests`
31
+ # switches to ADR-87 packed stat entries; a pre-7 blob mismatches the gate and loads as nil (clean cold
32
+ # rebuild). 8: each seed bundle additionally gains a comment-stripped `code_fingerprint` for the B1
33
+ # bundle-equality gate; a pre-8 blob mismatches and loads as nil (clean cold rebuild). 9: adds the ADR-88
34
+ # WD1 `plugin_fact_digest` (a fingerprint of the plugin fact SURFACE — ADR-9 facts, ADR-60 producer
35
+ # values, and `incremental_state_fingerprint` hooks — that a cached diagnostic can depend on but the
36
+ # global fingerprint does not capture); a pre-9 blob mismatches the SCHEMA gate and loads as nil (a clean
37
+ # cold rebuild — no migration). 10: ADR-89 WD1 adds a per-file `declaration_signature` to each seed
38
+ # bundle (the per-def parameter-shape / visibility / ancestry surface the declaration-stability gate
39
+ # compares) and WD2 adds `return_summaries` (per-def observed-key return descriptors + mutation-effect
40
+ # sets the behavioural-stability gate compares); a pre-10 blob mismatches the SCHEMA gate and loads as
41
+ # nil (a clean cold rebuild — no migration).
42
+ SCHEMA = 10
29
43
 
30
44
  # The persisted per-file state.
31
45
  # `cache` maps an analyzed file to its diagnostics.
@@ -39,9 +53,25 @@ module Rigor
39
53
  # ADR-46 slice 3:
40
54
  # `missing` maps a consumer to Set<"kind:name"> it looked up and missed.
41
55
  # `class_decls` maps a path to Set<qualified class name> it declares.
56
+ # ADR-85 WD2:
57
+ # `seed_bundles` maps an analyzed path to its per-file discovery contribution (plain-data tables +
58
+ # `(node_id, name, fingerprint)` def-node handles + content digest), so a warm recheck rebuilds the
59
+ # cross-file index by folding bundles instead of parsing every file.
60
+ # ADR-88 WD1:
61
+ # `plugin_fact_digest` is a SHA-256 hex fingerprint of the plugin fact surface at the run that wrote the
62
+ # snapshot (or nil for a plugin-free project). A warm recheck recomputes it and, on a mismatch, discards
63
+ # the snapshot and runs a full analysis — the guard for a plugin sig/catalog edit that the global
64
+ # fingerprint cannot see.
65
+ # ADR-89 WD2:
66
+ # `return_summaries` maps a `[path, "Class#method" | "Class.method"]` to the callee's persisted
67
+ # behavioural surface `{ keys:, returns:, effects: }` — the observed `[receiver, arg_types]` call keys
68
+ # (Marshal-clean type tuples), their `describe(:short)` return descriptors, and the content-mutated
69
+ # parameter positions. A recheck re-evaluates a declaration-stable changed callee at these keys and,
70
+ # when every return + the effects are unchanged, skips its symbol dependents.
42
71
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
43
72
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
44
- :missing, :class_decls)
73
+ :missing, :class_decls, :seed_bundles, :plugin_fact_digest,
74
+ :return_summaries)
45
75
 
46
76
  # The global fingerprint that gates a snapshot load: a digest of the inputs whose change requires a full
47
77
  # rebuild — the engine version + schema, the resolved configuration, the analysis **roots** (the path
@@ -104,7 +134,10 @@ module Rigor
104
134
  ancestry_sources: data[:ancestry_sources] || {},
105
135
  symbol_fingerprints: data[:symbol_fingerprints] || {},
106
136
  missing: data[:missing] || {},
107
- class_decls: data[:class_decls] || {}
137
+ class_decls: data[:class_decls] || {},
138
+ seed_bundles: data[:seed_bundles] || {},
139
+ plugin_fact_digest: data[:plugin_fact_digest],
140
+ return_summaries: data[:return_summaries] || {}
108
141
  )
109
142
  rescue StandardError
110
143
  nil
@@ -122,7 +155,10 @@ module Rigor
122
155
  ancestry_sources: payload.ancestry_sources,
123
156
  symbol_fingerprints: payload.symbol_fingerprints,
124
157
  missing: payload.missing,
125
- class_decls: payload.class_decls
158
+ class_decls: payload.class_decls,
159
+ seed_bundles: payload.seed_bundles,
160
+ plugin_fact_digest: payload.plugin_fact_digest,
161
+ return_summaries: payload.return_summaries
126
162
  )
127
163
  blob = Zlib::Deflate.deflate(raw)
128
164
  tmp = "#{@path}.#{Process.pid}.tmp"
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
 
5
5
  require_relative "descriptor"
6
+ require_relative "file_digest"
6
7
 
7
8
  module Rigor
8
9
  module Cache
@@ -17,15 +18,36 @@ module Rigor
17
18
  Descriptor.new(
18
19
  gems: [rbs_gem_entry],
19
20
  files: file_entries(loader),
20
- configs: [libraries_entry(loader), virtual_rbs_entry(loader)].compact
21
+ configs: config_entries(loader)
21
22
  )
22
23
  end
23
24
 
25
+ # Lazy-files variant for the ADR-45 run-diagnostics record-and-validate cache. The cache KEY reads only
26
+ # `gems` + `configs` ({Runner#run_key_descriptor}); the RBS signature-tree `files` are read solely on a
27
+ # MISS, by the dependency descriptor ({Runner#run_dependency_descriptor}). So a warm HIT never digests
28
+ # the (large, vendored) RBS tree. {RunDescriptor} is NOT a {Descriptor} — it is never composed, hashed,
29
+ # or `==`'d, only its three readers are consulted — so deferring `files` costs no soundness, and `gems`
30
+ # / `configs` are byte-identical to {.build} (the key is unchanged).
31
+ def self.build_run(loader)
32
+ RunDescriptor.new(loader: loader, gems: [rbs_gem_entry], configs: config_entries(loader))
33
+ end
34
+
35
+ # The `gems` + `configs` slots the run cache key reads. Cheap — no RBS env load, no file digesting
36
+ # (only the configured library names + any plugin-synthesised virtual RBS are hashed).
37
+ def self.config_entries(loader)
38
+ [libraries_entry(loader.libraries), virtual_rbs_entry(loader)].compact
39
+ end
40
+
41
+ # Public (ADR-87 WD4) so the boot-slimming run-cache probe reconstructs the identical `gems` +
42
+ # `rbs.libraries` key slots the runner writes, from the config-derived library list, without a loader.
24
43
  def self.rbs_gem_entry
25
44
  Descriptor::GemEntry.new(name: "rbs", requirement: ">= 0", locked: ::RBS::VERSION.to_s)
26
45
  end
27
46
 
28
- def self.file_entries(loader)
47
+ # @param comparator [Symbol] `:digest` (default) for the env-cache KEY descriptor ({.build}), where the
48
+ # value must be deterministic; `:stat` (ADR-87 WD1) for the validation-only run-dependency descriptor
49
+ # ({RunDescriptor#files}), where the stat tier short-circuits the SHA-256 on an unmoved file.
50
+ def self.file_entries(loader, comparator: :digest)
29
51
  roots = loader.signature_paths +
30
52
  Rigor::Environment::RbsLoader.vendored_gem_sig_paths +
31
53
  Rigor::Environment::RbsLoader.core_overlay_sig_paths
@@ -33,17 +55,20 @@ module Rigor
33
55
  next [] unless root.directory?
34
56
 
35
57
  Dir.glob(root.join("**", "*.rbs")).map do |path|
36
- Descriptor::FileEntry.new(
37
- path: path,
38
- comparator: :digest,
39
- value: Digest::SHA256.file(path).hexdigest
40
- )
58
+ digest = FileDigest.hexdigest(path)
59
+ if comparator == :stat
60
+ Descriptor::FileEntry.stat(path: path, digest: digest)
61
+ else
62
+ Descriptor::FileEntry.new(path: path, comparator: :digest, value: digest)
63
+ end
41
64
  end
42
65
  end
43
66
  end
44
67
 
45
- def self.libraries_entry(loader)
46
- sorted = loader.libraries.map(&:to_s).sort
68
+ # @param library_names [Array<String, Symbol>] the loader's merged library list (or, on the WD4 probe
69
+ # path, `Environment::DEFAULT_LIBRARIES + config.libraries` reconstructed without a loader).
70
+ def self.libraries_entry(library_names)
71
+ sorted = library_names.map(&:to_s).sort
47
72
  Descriptor::ConfigEntry.new(
48
73
  key: "rbs.libraries",
49
74
  value_hash: Digest::SHA256.hexdigest(sorted.join("\n"))
@@ -66,7 +91,28 @@ module Rigor
66
91
  )
67
92
  end
68
93
 
69
- private_class_method :rbs_gem_entry, :file_entries, :libraries_entry, :virtual_rbs_entry
94
+ private_class_method :virtual_rbs_entry
95
+
96
+ # The lazy-files run descriptor {RbsDescriptor.build_run} returns. Exposes the three readers the
97
+ # run-diagnostics cache consults — `gems` + `configs` are supplied eagerly (they feed the cache KEY,
98
+ # and are cheap); `files` (the RBS signature-tree digests, read only on a MISS) is computed once on
99
+ # first access and memoised, so a warm HIT never pays for it.
100
+ class RunDescriptor
101
+ attr_reader :gems, :configs
102
+
103
+ def initialize(loader:, gems:, configs:)
104
+ @loader = loader
105
+ @gems = gems
106
+ @configs = configs
107
+ end
108
+
109
+ def files
110
+ # ADR-87 WD1 — this descriptor is validated (never a cache key), so the RBS signature tree rides the
111
+ # stat-then-digest `:stat` tier: a warm run stat-checks the (large, vendored) tree instead of
112
+ # re-hashing it.
113
+ @files ||= RbsDescriptor.file_entries(@loader, comparator: :stat)
114
+ end
115
+ end
70
116
  end
71
117
  end
72
118
  end
@@ -7,6 +7,7 @@ require "monitor"
7
7
  require "securerandom"
8
8
  require "zlib"
9
9
 
10
+ require_relative "../version"
10
11
  require_relative "descriptor"
11
12
 
12
13
  module Rigor
@@ -30,6 +31,27 @@ module Rigor
30
31
  # bytes.
31
32
  FORMAT_VERSION = 2
32
33
 
34
+ # Payload ABI version. Store values are mostly Marshal blobs of Rigor/RBS objects, so a Rigor release
35
+ # upgrade is an ABI boundary even when the byte layout and descriptor schema are unchanged. Folding the
36
+ # gem version into the root marker makes installed-version upgrades rebuild rather than silently reuse a
37
+ # blob whose class layout still happens to unmarshal.
38
+ PAYLOAD_ABI_VERSION = Rigor::VERSION
39
+
40
+ # Whole-project producers are content-keyed, so dependency / signature churn writes a new entry and leaves
41
+ # the old generation unreachable. The global 256 MB cap is intentionally generous and often never fires on
42
+ # one project, so these producers get a small generation cap as a second compaction axis. Per-file / plugin
43
+ # producers are deliberately absent from this table: many current entries under one producer id can be live.
44
+ GENERATION_CAP_BY_PRODUCER = {
45
+ "analysis.run-diagnostics" => 16,
46
+ "rbs.class_ancestor_table" => 2,
47
+ "rbs.class_type_param_names" => 2,
48
+ "rbs.constant_type_table" => 2,
49
+ "rbs.environment" => 2,
50
+ "rbs.known_class_names" => 2
51
+ }.freeze
52
+
53
+ STALE_TEMP_FILE_AGE_SECONDS = 60 * 60
54
+
33
55
  # Header literal: 5-byte ASCII magic, 1-byte separator, 1-byte format version.
34
56
  HEADER = "RIGOR\x00#{FORMAT_VERSION.chr}".b.freeze
35
57
 
@@ -37,17 +59,19 @@ module Rigor
37
59
 
38
60
  # @param root [String] cache root directory.
39
61
  # @param read_only [Boolean] when true, every disk-side side-effect is suppressed: `fetch_or_compute`
40
- # still reads existing entries (hits) and still runs the producer block on miss, but it does NOT
41
- # write the produced value to disk, does NOT update the `schema_version.txt` marker, and does NOT
42
- # touch the on-disk root directory. The in-process memo is still populated so repeated lookups within
43
- # the same run stay cheap. Used by editor mode so multiple buffer-mode invocations can read from the
44
- # same cache concurrently without churning it. See `docs/design/20260516-editor-mode.md` § "Cache
45
- # behaviour".
62
+ # still reads existing entries (hits, gated on a current `schema_version.txt` marker see
63
+ # {#ensure_schema_version!}) and still runs the producer block on miss, but it does NOT write the
64
+ # produced value to disk, does NOT update the marker, and does NOT touch the on-disk root directory.
65
+ # The in-process memo is still populated so repeated lookups within the same run stay cheap. Used by
66
+ # editor mode so multiple buffer-mode invocations can read from the same cache concurrently without
67
+ # churning it. See `docs/design/20260516-editor-mode.md` § "Cache behaviour".
46
68
  def initialize(root:, read_only: false, max_bytes: nil)
47
69
  @root = root.to_s.dup.freeze
48
70
  @read_only = read_only
49
71
  @max_bytes = max_bytes&.then { |n| Integer(n) }
50
- @schema_version_ensured = false
72
+ # Tri-state: nil = not yet checked, true/false = the disk tier's availability for this Store's
73
+ # lifetime (see {#ensure_schema_version!}).
74
+ @disk_available = nil
51
75
  @hits = 0
52
76
  @misses = 0
53
77
  @writes = 0
@@ -100,7 +124,7 @@ module Rigor
100
124
  # the eviction cap forever. Folding the format version into the marker routes the bump through the
101
125
  # established clear-the-root path instead.
102
126
  def self.schema_marker_value
103
- "#{Descriptor::SCHEMA_VERSION}.#{FORMAT_VERSION}"
127
+ "#{PAYLOAD_ABI_VERSION}.#{Descriptor::SCHEMA_VERSION}.#{FORMAT_VERSION}"
104
128
  end
105
129
 
106
130
  def self.disk_inventory(root:)
@@ -154,7 +178,7 @@ module Rigor
154
178
  def fetch_or_compute(producer_id:, params:, descriptor:,
155
179
  serialize: nil, deserialize: nil, &block)
156
180
  validate_producer_id!(producer_id)
157
- ensure_schema_version!
181
+ disk = ensure_schema_version!
158
182
 
159
183
  key = descriptor.cache_key_for(producer_id: producer_id, params: params)
160
184
  memo_key = [producer_id, key].freeze
@@ -164,8 +188,8 @@ module Rigor
164
188
  return memoed
165
189
  end
166
190
 
167
- path = entry_path(producer_id, key)
168
- cached = read_entry(path, deserialize: deserialize)
191
+ path = disk ? entry_path(producer_id, key) : nil
192
+ cached = path && read_entry(path, deserialize: deserialize)
169
193
  unless cached.nil?
170
194
  @monitor.synchronize do
171
195
  record(:hits, producer_id)
@@ -175,10 +199,10 @@ module Rigor
175
199
  end
176
200
 
177
201
  value = block.call
178
- write_entry(path, descriptor, value, serialize: serialize) unless @read_only
202
+ wrote = path && try_write_entry(path, descriptor, value, serialize: serialize)
179
203
  @monitor.synchronize do
180
204
  record(:misses, producer_id)
181
- record(:writes, producer_id) unless @read_only
205
+ record(:writes, producer_id) if wrote
182
206
  @memo[memo_key] = value
183
207
  end
184
208
  value
@@ -195,29 +219,18 @@ module Rigor
195
219
  # validation always re-checks the filesystem — but a single run only looks up once.
196
220
  def fetch_or_validate(producer_id:, key_descriptor:, params: {}, serialize: nil, deserialize: nil)
197
221
  validate_producer_id!(producer_id)
198
- ensure_schema_version!
222
+ disk = ensure_schema_version!
199
223
 
200
224
  key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
201
- path = entry_path(producer_id, key)
202
- cached = read_entry(path, deserialize: deserialize)
203
- if cached && (pair = cached.value).is_a?(Array) && pair.size == 2 &&
204
- pair[1].is_a?(Descriptor) && pair[1].fresh?
225
+ path = disk ? entry_path(producer_id, key) : nil
226
+ cached = path && read_entry(path, deserialize: deserialize)
227
+ if (validated = fresh_pair_value(cached))
205
228
  @monitor.synchronize { record(:hits, producer_id) }
206
- return pair[0]
229
+ return validated[0]
207
230
  end
208
231
 
209
232
  value, dependency_descriptor = block_given? ? yield : [nil, Descriptor.new]
210
- wrote = false
211
- unless @read_only
212
- # A cache write must never break the run. If the value is not Marshal-clean (or any disk error
213
- # occurs) skip caching and return the freshly-computed value — the next run recomputes.
214
- begin
215
- write_entry(path, key_descriptor, [value, dependency_descriptor], serialize: serialize)
216
- wrote = true
217
- rescue StandardError
218
- wrote = false
219
- end
220
- end
233
+ wrote = path && try_write_entry(path, key_descriptor, [value, dependency_descriptor], serialize: serialize)
221
234
  @monitor.synchronize do
222
235
  record(:misses, producer_id)
223
236
  record(:writes, producer_id) if wrote
@@ -225,27 +238,48 @@ module Rigor
225
238
  value
226
239
  end
227
240
 
228
- # ADR-6 § "Eviction" LRU pass over the on-disk cache. No-op when `max_bytes:` was not configured or
229
- # the store is read-only. Walks all `.entry` files, sorts by mtime ascending (oldest = least recently
230
- # used), and unlinks from the oldest until the total is at or below the cap. Touch-on-disk-read
231
- # ({read_entry}) is the cross-process LRU signal: every disk hit (not in-process-memo hit) updates the
232
- # mtime so recently-read entries survive the eviction pass. Any FS error is swallowed — eviction must
233
- # never break a run.
241
+ # ADR-87 WD4the READ half of {#fetch_or_validate} with no compute and no write: returns the cached
242
+ # value on a fresh hit, nil on a miss / stale / unavailable-disk. The boot-slimming hit probe calls this
243
+ # to serve a run's diagnostics WITHOUT loading the inference engine it never runs a producer block, so
244
+ # there is nothing to write. Records a hit (for `--cache-stats` parity) but never a miss (a probe miss
245
+ # hands off to the full path, which records its own).
246
+ def peek_validated(producer_id:, key_descriptor:, params: {}, deserialize: nil)
247
+ validate_producer_id!(producer_id)
248
+ return nil unless ensure_schema_version!
249
+
250
+ key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
251
+ cached = read_entry(entry_path(producer_id, key), deserialize: deserialize)
252
+ validated = fresh_pair_value(cached)
253
+ return nil if validated.nil?
254
+
255
+ @monitor.synchronize { record(:hits, producer_id) }
256
+ validated[0]
257
+ end
258
+
259
+ # ADR-6 § "Eviction" — compaction pass over the on-disk cache. No-op when the store is read-only. Stale
260
+ # temp file cleanup and the whole-project generation cap run regardless of `max_bytes:` — they reclaim
261
+ # provably-dead bytes (leaked temp files, unreachable content-keyed generations) rather than enforcing a
262
+ # size budget, so an explicitly unbounded store (`max_bytes: nil`) still benefits from them. The
263
+ # size-based LRU pass below stays gated on `max_bytes:` being configured: it walks all remaining
264
+ # `.entry` files, sorts by mtime ascending (oldest = least recently used), and unlinks from the oldest
265
+ # until the total is at or below the cap. Touch-on-disk-read ({read_entry}) is the cross-process LRU
266
+ # signal: every disk hit (not in-process-memo hit) updates the mtime so recently-read entries survive
267
+ # the eviction pass. Any FS error is swallowed — eviction must never break a run.
234
268
  def evict!
235
- return if @max_bytes.nil? || @read_only
269
+ return if @read_only
236
270
 
237
- entries = collect_entry_stats
238
- total = entries.sum { |e| e[:bytes] }
271
+ cleanup_stale_temp_files
272
+ entries = evict_excess_generations(collect_entry_stats)
273
+ return if @max_bytes.nil?
274
+
275
+ total = entries.sum { |e| e[:bytes] }
239
276
  return if total <= @max_bytes
240
277
 
241
278
  entries.sort_by! { |e| e[:mtime] }
242
279
  entries.each do |entry|
243
280
  break if total <= @max_bytes
244
281
 
245
- File.unlink(entry[:path])
246
- total -= entry[:bytes]
247
- rescue StandardError
248
- next
282
+ total -= entry[:bytes] if unlink_entry(entry[:path])
249
283
  end
250
284
  nil
251
285
  rescue StandardError
@@ -257,6 +291,19 @@ module Rigor
257
291
  Entry = Data.define(:descriptor_bytes, :value)
258
292
  private_constant :Entry
259
293
 
294
+ # A record-and-validate entry is a fresh hit iff it deserialised to a `[value, dependency_descriptor]`
295
+ # pair whose descriptor still validates against the filesystem. Returns the pair on a fresh hit, nil
296
+ # otherwise. Shared by {#fetch_or_validate} and {#peek_validated} so both apply identical hit criteria.
297
+ def fresh_pair_value(cached)
298
+ return nil if cached.nil?
299
+
300
+ pair = cached.value
301
+ return nil unless pair.is_a?(Array) && pair.size == 2 &&
302
+ pair[1].is_a?(Descriptor) && pair[1].fresh?
303
+
304
+ pair
305
+ end
306
+
260
307
  def record(counter, producer_id)
261
308
  case counter
262
309
  when :hits then @hits += 1
@@ -372,38 +419,68 @@ module Rigor
372
419
  File.open(path, File::RDWR | File::CREAT, 0o644) do |lock_fd|
373
420
  lock_fd.flock(File::LOCK_EX)
374
421
  tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
375
- File.open(tmp, "wb") do |f|
376
- f.write(body)
377
- f.fsync
422
+ begin
423
+ File.open(tmp, "wb") do |f|
424
+ f.write(body)
425
+ f.fsync
426
+ end
427
+ File.rename(tmp, path)
428
+ ensure
429
+ # A failed write/rename must not leak its temp file — rely on the 1-hour `cleanup_stale_temp_files`
430
+ # sweep only as a backstop for crashes that skip this ensure entirely.
431
+ unlink_entry(tmp) if File.exist?(tmp)
378
432
  end
379
- File.rename(tmp, path)
433
+ fsync_directory(File.dirname(path))
380
434
  end
381
435
  end
382
436
 
437
+ # Checks (and, for a writable store, repairs) the `schema_version.txt` marker, and reports whether the
438
+ # disk tier is usable for the rest of this Store's lifetime. The result is memoized in `@disk_available`
439
+ # — one check per Store is enough; a benign double-check under a thread race would just repeat
440
+ # idempotent work.
441
+ #
442
+ # A writable store clears the cache root and rewrites the marker on a stale/missing marker, then reports
443
+ # available. A read-only store never touches the root — no mkdir, no marker write, no destructive clear
444
+ # — so it reports available ONLY when the on-disk marker already matches current exactly; a stale or
445
+ # missing marker (e.g. a Rigor upgrade with no writable run yet, as in LSP / editor mode) reports
446
+ # unavailable rather than risk unmarshalling a payload from a different ABI. Any filesystem failure
447
+ # (permission, disk full, deleted root) also reports unavailable, degrading this Store instance to
448
+ # in-memory-memo-only for the rest of its lifetime — the producer block still runs, its result still
449
+ # lands in `@memo`, and disk reads/writes are simply skipped.
450
+ #
451
+ # @return [Boolean]
383
452
  def ensure_schema_version!
384
- # Read-only stores never touch the cache root — no mkdir, no marker write, no destructive clear on
385
- # schema mismatch. A stale or wrong-schema marker simply yields nothing back (entries read through
386
- # the version check are content-keyed, so a write under the new schema never collides with a read
387
- # under the old). The next writable run will repair the cache.
388
- return if @read_only
389
- # The marker is process-stable; one check per Store is enough (a benign double-check under a thread
390
- # race just repeats idempotent work).
391
- return if @schema_version_ensured
453
+ return @disk_available unless @disk_available.nil?
454
+
455
+ @disk_available = @read_only ? read_only_marker_current? : repair_writable_marker!
456
+ end
457
+
458
+ def read_only_marker_current?
459
+ marker = File.join(@root, "schema_version.txt")
460
+ return false unless File.file?(marker)
461
+
462
+ File.read(marker).strip == self.class.schema_marker_value
463
+ rescue StandardError
464
+ false
465
+ end
392
466
 
393
- @schema_version_ensured = true
467
+ def repair_writable_marker!
394
468
  FileUtils.mkdir_p(@root)
395
469
  marker = File.join(@root, "schema_version.txt")
396
470
  current = self.class.schema_marker_value
397
471
 
398
472
  if File.file?(marker)
399
473
  on_disk = File.read(marker).strip
400
- return if on_disk == current
474
+ return true if on_disk == current
401
475
 
402
476
  clear_cache_root!
403
477
  end
404
478
 
405
479
  FileUtils.mkdir_p(@root)
406
480
  File.write(marker, "#{current}\n")
481
+ true
482
+ rescue StandardError
483
+ false
407
484
  end
408
485
 
409
486
  def clear_cache_root!
@@ -412,6 +489,27 @@ module Rigor
412
489
  end
413
490
  end
414
491
 
492
+ # Shared write path for both {#fetch_or_compute} and {#fetch_or_validate}. A cache write must never
493
+ # break the run: filesystem-side failures (permission, disk full, deleted root, read-only mount) are
494
+ # swallowed and read as "did not write". Programmer errors — a custom serializer that doesn't return a
495
+ # `String`, or any other producer contract violation — still raise so the bug is visible.
496
+ def try_write_entry(path, descriptor, value, serialize: nil)
497
+ return false if @read_only
498
+
499
+ write_entry(path, descriptor, value, serialize: serialize)
500
+ true
501
+ rescue SystemCallError, IOError
502
+ false
503
+ end
504
+
505
+ # Best-effort durability for the rename itself. Some platforms cannot fsync directories (or do not need to),
506
+ # so failures are ignored; the entry envelope still turns any lost/partial write into a miss.
507
+ def fsync_directory(dir)
508
+ File.open(dir, File::RDONLY, &:fsync)
509
+ rescue StandardError
510
+ nil
511
+ end
512
+
415
513
  # LEB128 unsigned varint encoder/decoder. Lengths fit easily in five bytes (cap at 2^35); the cache
416
514
  # layer never writes a value larger than that in practice.
417
515
  def write_varint(bytes, value)
@@ -437,17 +535,66 @@ module Rigor
437
535
  nil
438
536
  end
439
537
 
440
- # Returns an array of `{ path:, mtime:, bytes: }` hashes for every `.entry` file under the cache root,
441
- # skipping unreadable entries.
538
+ def cleanup_stale_temp_files
539
+ cutoff = Time.now - STALE_TEMP_FILE_AGE_SECONDS
540
+ Dir.glob(File.join(@root, "**", "*.tmp.*")).each do |path|
541
+ next unless File.file?(path)
542
+ next if File.mtime(path) > cutoff
543
+
544
+ unlink_entry(path)
545
+ rescue StandardError
546
+ next
547
+ end
548
+ rescue StandardError
549
+ nil
550
+ end
551
+
552
+ def evict_excess_generations(entries)
553
+ removed = {}
554
+ entries.group_by { |entry| entry[:producer] }.each do |producer, producer_entries|
555
+ cap = GENERATION_CAP_BY_PRODUCER[producer]
556
+ next if cap.nil? || producer_entries.size <= cap
557
+
558
+ producer_entries.sort_by { |entry| [entry[:mtime], entry[:path]] }
559
+ .first(producer_entries.size - cap)
560
+ .each do |entry|
561
+ removed[entry[:path]] = true if unlink_entry(entry[:path])
562
+ end
563
+ end
564
+ return entries if removed.empty?
565
+
566
+ entries.reject { |entry| removed[entry[:path]] }
567
+ end
568
+
569
+ def unlink_entry(path)
570
+ File.unlink(path)
571
+ true
572
+ rescue StandardError
573
+ false
574
+ end
575
+
576
+ # Returns an array of `{ path:, producer:, mtime:, bytes: }` hashes for every `.entry` file under the
577
+ # cache root, skipping unreadable entries.
442
578
  def collect_entry_stats
443
579
  Dir.glob(File.join(@root, "**", "*.entry")).filter_map do |path|
444
580
  stat = File.stat(path)
445
- { path: path, mtime: stat.mtime, bytes: stat.size }
581
+ producer = producer_id_for_entry(path)
582
+ next nil if producer.nil?
583
+
584
+ { path: path, producer: producer, mtime: stat.mtime, bytes: stat.size }
446
585
  rescue StandardError
447
586
  nil
448
587
  end
449
588
  end
450
589
 
590
+ def producer_id_for_entry(path)
591
+ root_prefix = @root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}"
592
+ return nil unless path.start_with?(root_prefix)
593
+
594
+ producer = path.delete_prefix(root_prefix).split(File::SEPARATOR, 2).first
595
+ producer.empty? ? nil : producer
596
+ end
597
+
451
598
  def read_varint(bytes, offset)
452
599
  result = 0
453
600
  shift = 0