rigortype 0.1.16 → 0.1.17

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  3. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  4. data/lib/rigor/analysis/check_rules.rb +149 -70
  5. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  6. data/lib/rigor/analysis/diagnostic.rb +18 -0
  7. data/lib/rigor/analysis/incremental.rb +162 -0
  8. data/lib/rigor/analysis/incremental_session.rb +337 -0
  9. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  10. data/lib/rigor/analysis/runner.rb +434 -37
  11. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  13. data/lib/rigor/cache/descriptor.rb +50 -49
  14. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  15. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  16. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  17. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  18. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  19. data/lib/rigor/cache/rbs_environment.rb +2 -8
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  21. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  22. data/lib/rigor/cache/store.rb +99 -1
  23. data/lib/rigor/cli/annotate_command.rb +2 -7
  24. data/lib/rigor/cli/baseline_command.rb +2 -7
  25. data/lib/rigor/cli/command.rb +47 -0
  26. data/lib/rigor/cli/coverage_command.rb +3 -23
  27. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  28. data/lib/rigor/cli/diff_command.rb +3 -7
  29. data/lib/rigor/cli/explain_command.rb +2 -7
  30. data/lib/rigor/cli/lsp_command.rb +3 -7
  31. data/lib/rigor/cli/mcp_command.rb +3 -7
  32. data/lib/rigor/cli/options.rb +57 -0
  33. data/lib/rigor/cli/plugin_command.rb +3 -7
  34. data/lib/rigor/cli/plugins_command.rb +2 -7
  35. data/lib/rigor/cli/renderable.rb +26 -0
  36. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  37. data/lib/rigor/cli/skill_command.rb +3 -7
  38. data/lib/rigor/cli/triage_command.rb +2 -7
  39. data/lib/rigor/cli/type_of_command.rb +5 -38
  40. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  41. data/lib/rigor/cli/type_scan_command.rb +3 -23
  42. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  43. data/lib/rigor/cli.rb +125 -43
  44. data/lib/rigor/configuration/dependencies.rb +18 -1
  45. data/lib/rigor/configuration/severity_profile.rb +22 -3
  46. data/lib/rigor/configuration.rb +13 -3
  47. data/lib/rigor/environment/rbs_loader.rb +76 -3
  48. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  49. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  50. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  51. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  52. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  53. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  54. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  55. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  58. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  59. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  64. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  69. data/lib/rigor/inference/expression_typer.rb +140 -20
  70. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  71. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  72. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  73. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  74. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  75. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  76. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  77. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  78. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  79. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  80. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  81. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  82. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  83. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  84. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  85. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  86. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  87. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  88. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  89. data/lib/rigor/inference/method_dispatcher.rb +99 -59
  90. data/lib/rigor/inference/narrowing.rb +202 -5
  91. data/lib/rigor/inference/scope_indexer.rb +134 -7
  92. data/lib/rigor/inference/statement_evaluator.rb +105 -26
  93. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  94. data/lib/rigor/language_server/completion_provider.rb +4 -4
  95. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  96. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  97. data/lib/rigor/language_server/hover_provider.rb +4 -4
  98. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  99. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  100. data/lib/rigor/plugin/base.rb +20 -4
  101. data/lib/rigor/plugin/registry.rb +39 -1
  102. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  103. data/lib/rigor/rbs_extended.rb +39 -0
  104. data/lib/rigor/scope.rb +123 -9
  105. data/lib/rigor/type/acceptance_router.rb +19 -0
  106. data/lib/rigor/type/accepts_result.rb +3 -10
  107. data/lib/rigor/type/app.rb +3 -7
  108. data/lib/rigor/type/bot.rb +2 -3
  109. data/lib/rigor/type/bound_method.rb +5 -12
  110. data/lib/rigor/type/combinator.rb +17 -0
  111. data/lib/rigor/type/constant.rb +2 -3
  112. data/lib/rigor/type/data_class.rb +80 -0
  113. data/lib/rigor/type/data_instance.rb +100 -0
  114. data/lib/rigor/type/difference.rb +5 -10
  115. data/lib/rigor/type/dynamic.rb +5 -10
  116. data/lib/rigor/type/hash_shape.rb +5 -15
  117. data/lib/rigor/type/integer_range.rb +5 -10
  118. data/lib/rigor/type/intersection.rb +5 -10
  119. data/lib/rigor/type/nominal.rb +5 -10
  120. data/lib/rigor/type/refined.rb +5 -10
  121. data/lib/rigor/type/singleton.rb +5 -10
  122. data/lib/rigor/type/top.rb +2 -3
  123. data/lib/rigor/type/tuple.rb +5 -10
  124. data/lib/rigor/type/union.rb +5 -10
  125. data/lib/rigor/type.rb +2 -0
  126. data/lib/rigor/value_semantics.rb +77 -0
  127. data/lib/rigor/version.rb +1 -1
  128. data/lib/rigor.rb +1 -0
  129. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  130. data/sig/rigor/cache.rbs +19 -0
  131. data/sig/rigor/inference.rbs +22 -0
  132. data/sig/rigor/rbs_extended.rbs +2 -0
  133. data/sig/rigor/scope.rbs +5 -0
  134. data/sig/rigor/type.rbs +58 -1
  135. data/sig/rigor.rbs +6 -1
  136. metadata +22 -1
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # ADR-24 slice 4a — records every *implicit-self* method call the
6
+ # inference engine could not resolve to any method (RBS tier miss +
7
+ # user-class ancestor-walk miss + not a `Dynamic` receiver), captured
8
+ # at the single engine choke-point where such a call falls through to
9
+ # `Dynamic[top]` (`ExpressionTyper#call_type_for` → `fallback_for`).
10
+ #
11
+ # ## Why a recorder, not a check-rule
12
+ #
13
+ # Slice 4 attempt 1 reimplemented self-call resolution inside
14
+ # `CheckRules` and produced 135 false positives on Rigor's own `lib`
15
+ # ([ADR-24](../../../docs/adr/24-self-method-call-resolution.md)
16
+ # § "Slice 4"): the second, weaker resolution path could not see
17
+ # `module_function` siblings, `Data.define` / `Struct` accessors, or
18
+ # mixin-contributed methods that the *engine's* real resolution
19
+ # handles. Recording at the engine's own miss point reuses that real
20
+ # resolution — those methods resolve before the miss, so they never
21
+ # reach the recorder. This module is the ADR-46 / ADR-47 "collect at
22
+ # evaluation time, never recompute" lesson applied to self-calls.
23
+ #
24
+ # A later slice consumes the recorded misses behind a confidently-
25
+ # closed-class gate to emit `call.self-undefined-method`, behind its
26
+ # own external-corpus false-positive gate. This slice (4a) lands the
27
+ # plumbing OFF by default — {active?} is false on a normal run, so the
28
+ # instrumented choke-point pays a single integer read and records
29
+ # nothing. Recording is purely observational; it never changes a
30
+ # diagnostic.
31
+ #
32
+ # Modelled on {DependencyRecorder}: process-thread-local accumulator,
33
+ # a cheap disabled fast path, and a frozen snapshot for consumers.
34
+ module SelfCallResolutionRecorder
35
+ KEY = :__rigor_self_call_resolution_recorder__
36
+ private_constant :KEY
37
+
38
+ # One unresolved implicit-self call. `class_name` is the receiver's
39
+ # statically known class (the enclosing `self` type); `method_name`
40
+ # the called name; `node` the Prism `CallNode` (held in-memory so the
41
+ # `call.self-undefined-method` collector can resolve its scope from the
42
+ # scope index and apply the closed-class gate); `path` / `line` /
43
+ # `column` locate the call site for the diagnostic.
44
+ UnresolvedSelfCall = Data.define(:class_name, :method_name, :node, :path, :line, :column)
45
+
46
+ # Mutable per-consumer accumulator, frozen into a {Record} snapshot
47
+ # when {record_for} returns. Dedupes by the full call tuple so a
48
+ # method body re-typed under several call-site signatures records the
49
+ # miss once.
50
+ class Accumulator
51
+ attr_reader :consumer, :calls
52
+
53
+ def initialize(consumer)
54
+ @consumer = consumer
55
+ @calls = []
56
+ @seen = Set.new
57
+ end
58
+
59
+ def add(call)
60
+ return unless @seen.add?(call)
61
+
62
+ @calls << call
63
+ end
64
+
65
+ def snapshot
66
+ Record.new(consumer: consumer, calls: calls.dup.freeze)
67
+ end
68
+ end
69
+
70
+ Record = Data.define(:consumer, :calls)
71
+
72
+ # Module-level activation count so the disabled fast path ({active?})
73
+ # is a plain integer read rather than a `Thread.current` hash lookup —
74
+ # the instrumented choke-point is on the dispatch miss path, so a
75
+ # normal (non-recording) run must pay as little as possible.
76
+ @active_count = 0
77
+ @mutex = Mutex.new
78
+
79
+ module_function
80
+
81
+ # Activates recording for `consumer` (the path being analyzed) for the
82
+ # duration of the block and returns the frozen {Record}. Nests safely;
83
+ # restores the previous recorder on exit.
84
+ def record_for(consumer)
85
+ previous = Thread.current[KEY]
86
+ accumulator = Accumulator.new(consumer.to_s)
87
+ Thread.current[KEY] = accumulator
88
+ @mutex.synchronize { @active_count += 1 }
89
+ yield
90
+ accumulator.snapshot
91
+ ensure
92
+ Thread.current[KEY] = previous
93
+ @mutex.synchronize { @active_count -= 1 }
94
+ end
95
+
96
+ # Plain integer read (GVL-atomic) — no `Thread.current` lookup on the
97
+ # disabled fast path.
98
+ def active?
99
+ @active_count.positive?
100
+ end
101
+
102
+ # Records one unresolved implicit-self call. No-op when no consumer is
103
+ # active on this thread (another thread may have flipped {active?}).
104
+ def record(class_name:, method_name:, node:, path:, line:, column:)
105
+ accumulator = Thread.current[KEY]
106
+ return if accumulator.nil?
107
+
108
+ accumulator.add(
109
+ UnresolvedSelfCall.new(
110
+ class_name: class_name.to_s,
111
+ method_name: method_name.to_sym,
112
+ node: node,
113
+ path: path,
114
+ line: line,
115
+ column: column
116
+ )
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
@@ -134,8 +134,14 @@ module Rigor
134
134
  # @return [Array<String>] the candidate owner class names
135
135
  # for a bare method-name lookup. Empty when no override
136
136
  # names this method.
137
+ NO_OWNERS = [].freeze
138
+ private_constant :NO_OWNERS
139
+
140
+ # Consulted on every dispatch (`try_static_refinement`); the miss
141
+ # case is overwhelmingly common, so share one frozen empty array
142
+ # instead of allocating a fresh `[]` per non-refined method.
137
143
  def self.owners_for(method_name)
138
- OWNERS_BY_METHOD[method_name.to_sym] || []
144
+ OWNERS_BY_METHOD[method_name.to_sym] || NO_OWNERS
139
145
  end
140
146
  end
141
147
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require "json"
5
+ require_relative "../value_semantics"
5
6
 
6
7
  module Rigor
7
8
  module Cache
@@ -38,10 +39,14 @@ module Rigor
38
39
  # can mutate after the entry is in a Descriptor.
39
40
 
40
41
  class FileEntry
42
+ include Rigor::ValueSemantics
43
+
41
44
  VALID_COMPARATORS = %i[digest mtime exists].freeze
42
45
 
43
46
  attr_reader :path, :comparator, :value
44
47
 
48
+ value_fields :path, :comparator, :value
49
+
45
50
  def initialize(path:, comparator:, value:)
46
51
  unless VALID_COMPARATORS.include?(comparator)
47
52
  raise ArgumentError,
@@ -57,20 +62,15 @@ module Rigor
57
62
  def to_h
58
63
  { "path" => path, "comparator" => comparator.to_s, "value" => value }
59
64
  end
60
-
61
- def ==(other)
62
- other.is_a?(FileEntry) && other.path == path && other.comparator == comparator && other.value == value
63
- end
64
- alias eql? ==
65
-
66
- def hash
67
- [self.class, path, comparator, value].hash
68
- end
69
65
  end
70
66
 
71
67
  class GemEntry
68
+ include Rigor::ValueSemantics
69
+
72
70
  attr_reader :name, :requirement, :locked
73
71
 
72
+ value_fields :name, :requirement, :locked
73
+
74
74
  def initialize(name:, requirement:, locked: nil)
75
75
  @name = name.to_s.dup.freeze
76
76
  @requirement = requirement.to_s.dup.freeze
@@ -81,20 +81,15 @@ module Rigor
81
81
  def to_h
82
82
  { "name" => name, "requirement" => requirement, "locked" => locked }
83
83
  end
84
-
85
- def ==(other)
86
- other.is_a?(GemEntry) && other.name == name && other.requirement == requirement && other.locked == locked
87
- end
88
- alias eql? ==
89
-
90
- def hash
91
- [self.class, name, requirement, locked].hash
92
- end
93
84
  end
94
85
 
95
86
  class PluginEntry
87
+ include Rigor::ValueSemantics
88
+
96
89
  attr_reader :id, :version, :config_hash
97
90
 
91
+ value_fields :id, :version, :config_hash
92
+
98
93
  def initialize(id:, version:, config_hash: nil)
99
94
  @id = id.to_s.dup.freeze
100
95
  @version = version.to_s.dup.freeze
@@ -105,21 +100,15 @@ module Rigor
105
100
  def to_h
106
101
  { "id" => id, "version" => version, "config_hash" => config_hash }
107
102
  end
108
-
109
- def ==(other)
110
- other.is_a?(PluginEntry) &&
111
- other.id == id && other.version == version && other.config_hash == config_hash
112
- end
113
- alias eql? ==
114
-
115
- def hash
116
- [self.class, id, version, config_hash].hash
117
- end
118
103
  end
119
104
 
120
105
  class ConfigEntry
106
+ include Rigor::ValueSemantics
107
+
121
108
  attr_reader :key, :value_hash
122
109
 
110
+ value_fields :key, :value_hash
111
+
123
112
  def initialize(key:, value_hash:)
124
113
  @key = key.to_s.dup.freeze
125
114
  @value_hash = value_hash.to_s.dup.freeze
@@ -129,15 +118,6 @@ module Rigor
129
118
  def to_h
130
119
  { "key" => key, "value_hash" => value_hash }
131
120
  end
132
-
133
- def ==(other)
134
- other.is_a?(ConfigEntry) && other.key == key && other.value_hash == value_hash
135
- end
136
- alias eql? ==
137
-
138
- def hash
139
- [self.class, key, value_hash].hash
140
- end
141
121
  end
142
122
 
143
123
  # Per-(gem, version, mode) row carrying the cache slice
@@ -155,10 +135,14 @@ module Rigor
155
135
  # the inferred shapes depend on whether RBS overrides the
156
136
  # walk.
157
137
  class DependencyEntry
138
+ include Rigor::ValueSemantics
139
+
158
140
  VALID_MODES = %i[disabled when_missing full].freeze
159
141
 
160
142
  attr_reader :gem_name, :gem_version, :mode
161
143
 
144
+ value_fields :gem_name, :gem_version, :mode
145
+
162
146
  def initialize(gem_name:, gem_version:, mode:)
163
147
  unless VALID_MODES.include?(mode)
164
148
  raise ArgumentError,
@@ -174,18 +158,6 @@ module Rigor
174
158
  def to_h
175
159
  { "gem_name" => gem_name, "gem_version" => gem_version, "mode" => mode.to_s }
176
160
  end
177
-
178
- def ==(other)
179
- other.is_a?(DependencyEntry) &&
180
- other.gem_name == gem_name &&
181
- other.gem_version == gem_version &&
182
- other.mode == mode
183
- end
184
- alias eql? ==
185
-
186
- def hash
187
- [self.class, gem_name, gem_version, mode].hash
188
- end
189
161
  end
190
162
 
191
163
  # Raised when {.compose} encounters incompatible entries
@@ -206,6 +178,20 @@ module Rigor
206
178
  freeze
207
179
  end
208
180
 
181
+ # ADR-45 — re-validates this descriptor's recorded {FileEntry}s
182
+ # against the current filesystem. Used by the record-and-validate
183
+ # run-result cache: a value cached alongside its dependency
184
+ # descriptor is fresh iff every recorded file still matches. Only
185
+ # `files` are checked — non-file inputs (config / gems / version)
186
+ # belong in the cache *key*, not the validated dependency set — so
187
+ # a descriptor carrying any non-file slot is never considered fresh
188
+ # (it was built wrong for this use).
189
+ def fresh?
190
+ return false unless gems.empty? && plugins.empty? && configs.empty? && dependencies.empty?
191
+
192
+ files.all? { |entry| file_entry_fresh?(entry) }
193
+ end
194
+
209
195
  # File-comparator strictness ordering. `:digest` is strictest
210
196
  # (deterministic across machines); `:mtime` is cheaper but
211
197
  # local; `:exists` is the weakest signal. When two
@@ -290,6 +276,21 @@ module Rigor
290
276
 
291
277
  private
292
278
 
279
+ def file_entry_fresh?(entry)
280
+ case entry.comparator
281
+ when :digest
282
+ File.file?(entry.path) && Digest::SHA256.file(entry.path).hexdigest == entry.value
283
+ when :mtime
284
+ File.exist?(entry.path) && File.mtime(entry.path).to_i.to_s == entry.value
285
+ when :exists
286
+ File.exist?(entry.path).to_s == entry.value
287
+ else
288
+ false
289
+ end
290
+ rescue StandardError
291
+ false
292
+ end
293
+
293
294
  def sort_entries(entries, key)
294
295
  entries.sort_by { |e| e.to_h.fetch(key).to_s }
295
296
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+
6
+ module Rigor
7
+ module Cache
8
+ # ADR-46 — disk persistence for the incremental analyzer's per-file
9
+ # state, so a `--incremental` session survives across processes (one
10
+ # `rigor check` invocation reads the prior run's per-file diagnostics +
11
+ # dependency graph, re-analyzes only the changed closure, and serves the
12
+ # rest from disk).
13
+ #
14
+ # Unlike ADR-45's whole-run cache (record-and-validate ONE entry,
15
+ # invalidated by any analyzed-file change), this snapshot is loaded
16
+ # UNCONDITIONALLY when the global fingerprint matches — the per-file
17
+ # digests *inside* it drive the incremental re-analysis decision; they
18
+ # do not gate the load. The fingerprint captures the inputs whose change
19
+ # requires a full rebuild — the resolved configuration, the RBS
20
+ # environment, the engine version — but NOT the analyzed source
21
+ # contents. A fingerprint mismatch (config / gem / version change) drops
22
+ # the snapshot and forces a full re-analysis, the conservative
23
+ # direction.
24
+ #
25
+ # Every operation is fault-tolerant: a missing, unreadable, schema-
26
+ # mismatched, fingerprint-mismatched, or corrupt snapshot loads as nil
27
+ # (→ a cold full run), and a write failure is swallowed (→ the next run
28
+ # is cold). A cache must never break a run (the ADR-45 invariant).
29
+ class IncrementalSnapshot
30
+ # Bump when the on-disk shape changes so stale snapshots are ignored
31
+ # rather than mis-deserialized.
32
+ SCHEMA = 4
33
+
34
+ # The persisted per-file state.
35
+ # `cache` maps an analyzed file to its diagnostics.
36
+ # `sources` maps a consumer to the Set of source files it read from.
37
+ # `digests` maps a file to its content digest at analysis time.
38
+ # `analyzed` is the ordered analyzed-file list.
39
+ # ADR-46 slice 4:
40
+ # `symbol_sources` maps a consumer to { source_path → Set<"ClassName#method"> }.
41
+ # `ancestry_sources` maps a consumer to Set<source_path> (class-ancestry deps).
42
+ # `symbol_fingerprints` maps a path to { "ClassName#method" => sha256_hex }.
43
+ # ADR-46 slice 3:
44
+ # `missing` maps a consumer to Set<"kind:name"> it looked up and missed.
45
+ # `class_decls` maps a path to Set<qualified class name> it declares.
46
+ Payload = Data.define(:cache, :sources, :digests, :analyzed,
47
+ :symbol_sources, :ancestry_sources, :symbol_fingerprints,
48
+ :missing, :class_decls)
49
+
50
+ # The global fingerprint that gates a snapshot load: a digest of the
51
+ # inputs whose change requires a full rebuild — the engine version +
52
+ # schema, the resolved configuration, the analysis **roots** (the path
53
+ # arguments, e.g. `["lib"]`, NOT the expanded file list — so a snapshot
54
+ # is keyed to an invocation's roots but adding / removing a file under
55
+ # them is handled incrementally by the session, not a full rebuild), the
56
+ # resolved gem set (`Gemfile.lock` / `rbs_collection`), and the project's
57
+ # own RBS (`signature_paths` file contents). Built WITHOUT constructing
58
+ # the RBS environment so the warm path can gate the load cheaply, before
59
+ # the costly env build. The `--verify-incremental` gate is the safety net
60
+ # for any under-capture (it would surface as an incremental-vs-full
61
+ # mismatch). Returns nil on any error → the caller falls back to a
62
+ # non-persisted run.
63
+ def self.fingerprint(configuration:, roots:)
64
+ parts = [
65
+ "engine:#{Rigor::VERSION}:#{SCHEMA}",
66
+ "config:#{Digest::SHA256.hexdigest(Marshal.dump(configuration.to_h))}",
67
+ "roots:#{Array(roots).map(&:to_s).sort.join("\n")}",
68
+ "gems:#{digest_file_if_present('Gemfile.lock')}",
69
+ "rbs_collection:#{digest_file_if_present('rbs_collection.lock.yaml')}",
70
+ "sig:#{digest_signature_paths(configuration.signature_paths)}"
71
+ ]
72
+ Digest::SHA256.hexdigest(parts.join("\x00"))
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ def self.digest_file_if_present(path)
78
+ File.file?(path) ? Digest::SHA256.file(path).hexdigest : "absent"
79
+ end
80
+ private_class_method :digest_file_if_present
81
+
82
+ # Content-digest every `.rbs` under the configured signature paths
83
+ # (sorted for determinism) so a project RBS edit invalidates the
84
+ # snapshot. Sig trees are small; content (not mtime) keeps it stable
85
+ # across checkouts.
86
+ def self.digest_signature_paths(signature_paths)
87
+ globbed = Array(signature_paths).flat_map do |entry|
88
+ File.directory?(entry) ? Dir.glob(File.join(entry, "**", "*.rbs")) : [entry]
89
+ end
90
+ files = globbed.select { |path| File.file?(path) }.sort
91
+ digest = Digest::SHA256.new
92
+ files.each { |path| digest << path << "\0" << Digest::SHA256.file(path).hexdigest << "\0" }
93
+ digest.hexdigest
94
+ end
95
+ private_class_method :digest_signature_paths
96
+
97
+ def initialize(root:)
98
+ @path = File.join(root.to_s, "incremental", "snapshot.bin")
99
+ end
100
+
101
+ attr_reader :path
102
+
103
+ # The stored {Payload}, or nil when absent / unreadable / schema or
104
+ # fingerprint mismatch / corrupt. Never raises.
105
+ def load(fingerprint:)
106
+ data = Marshal.load(File.binread(@path)) # rubocop:disable Security/MarshalLoad
107
+ return nil unless data.is_a?(Hash) && data[:schema] == SCHEMA && data[:fingerprint] == fingerprint
108
+
109
+ Payload.new(
110
+ cache: data[:cache], sources: data[:sources],
111
+ digests: data[:digests], analyzed: data[:analyzed],
112
+ symbol_sources: data[:symbol_sources] || {},
113
+ ancestry_sources: data[:ancestry_sources] || {},
114
+ symbol_fingerprints: data[:symbol_fingerprints] || {},
115
+ missing: data[:missing] || {},
116
+ class_decls: data[:class_decls] || {}
117
+ )
118
+ rescue StandardError
119
+ nil
120
+ end
121
+
122
+ # Persist `payload` under `fingerprint`. Writes via a temp file +
123
+ # atomic rename so a concurrent reader never sees a half-written
124
+ # snapshot. Returns true on success, false on any failure (never
125
+ # raises).
126
+ def save(fingerprint:, payload:)
127
+ FileUtils.mkdir_p(File.dirname(@path))
128
+ blob = Marshal.dump(
129
+ schema: SCHEMA, fingerprint: fingerprint,
130
+ cache: payload.cache, sources: payload.sources,
131
+ digests: payload.digests, analyzed: payload.analyzed,
132
+ symbol_sources: payload.symbol_sources,
133
+ ancestry_sources: payload.ancestry_sources,
134
+ symbol_fingerprints: payload.symbol_fingerprints,
135
+ missing: payload.missing,
136
+ class_decls: payload.class_decls
137
+ )
138
+ tmp = "#{@path}.#{Process.pid}.tmp"
139
+ File.binwrite(tmp, blob)
140
+ File.rename(tmp, @path)
141
+ true
142
+ rescue StandardError
143
+ false
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbs_descriptor"
4
+
5
+ module Rigor
6
+ module Cache
7
+ # Base for the RBS-derived cache producers.
8
+ #
9
+ # Every producer (`RbsKnownClassNames`, `RbsConstantTable`,
10
+ # `RbsEnvironment`, the ancestor / type-param / definition tables, …)
11
+ # repeated the identical `fetch` wiring: build the RBS descriptor,
12
+ # then `store.fetch_or_compute` under the producer's id, yielding to
13
+ # the producer's `compute`. Only the `PRODUCER_ID` constant and the
14
+ # `compute(loader)` body actually differ between producers.
15
+ #
16
+ # Subclasses declare `PRODUCER_ID` and a (private) `self.compute`;
17
+ # this base owns `fetch`. `self::PRODUCER_ID` resolves the constant on
18
+ # the concrete subclass, and `compute(loader)` dispatches to its
19
+ # private class method. See the `_CacheProducer` RBS interface for the
20
+ # structural contract.
21
+ class RbsCacheProducer
22
+ def self.fetch(loader:, store:)
23
+ descriptor = RbsDescriptor.build(loader)
24
+ store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor) do
25
+ compute(loader)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rbs_descriptor"
4
+ require_relative "rbs_cache_producer"
4
5
 
5
6
  module Rigor
6
7
  module Cache
@@ -22,19 +23,12 @@ module Rigor
22
23
  # Cache descriptor shape is shared with every other cache
23
24
  # producer that depends on the RBS environment — see
24
25
  # {RbsDescriptor.build}.
25
- class RbsClassAncestorTable
26
+ class RbsClassAncestorTable < RbsCacheProducer
26
27
  PRODUCER_ID = "rbs.class_ancestor_table"
27
28
 
28
29
  # @param loader [Rigor::Environment::RbsLoader]
29
30
  # @param store [Rigor::Cache::Store]
30
31
  # @return [Hash{String => Array<String>}]
31
- def self.fetch(loader:, store:)
32
- descriptor = RbsDescriptor.build(loader)
33
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
34
- compute(loader)
35
- end
36
- end
37
-
38
32
  def self.compute(loader)
39
33
  table = {}
40
34
  loader.each_known_class_name do |name|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rbs_descriptor"
4
+ require_relative "rbs_cache_producer"
4
5
 
5
6
  module Rigor
6
7
  module Cache
@@ -22,19 +23,12 @@ module Rigor
22
23
  # Cache descriptor shape is shared with every other cache
23
24
  # producer that depends on the RBS environment — see
24
25
  # {RbsDescriptor.build}.
25
- class RbsClassTypeParamNames
26
+ class RbsClassTypeParamNames < RbsCacheProducer
26
27
  PRODUCER_ID = "rbs.class_type_param_names"
27
28
 
28
29
  # @param loader [Rigor::Environment::RbsLoader]
29
30
  # @param store [Rigor::Cache::Store]
30
31
  # @return [Hash{String => Array<Symbol>}]
31
- def self.fetch(loader:, store:)
32
- descriptor = RbsDescriptor.build(loader)
33
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
34
- compute(loader)
35
- end
36
- end
37
-
38
32
  def self.compute(loader)
39
33
  table = {}
40
34
  loader.each_known_class_name do |name|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rbs_descriptor"
4
+ require_relative "rbs_cache_producer"
4
5
 
5
6
  module Rigor
6
7
  module Cache
@@ -15,19 +16,12 @@ module Rigor
15
16
  # Cache descriptor shape is shared with every other cache
16
17
  # producer that depends on the RBS environment — see
17
18
  # {RbsDescriptor.build} for the slot definitions.
18
- class RbsConstantTable
19
+ class RbsConstantTable < RbsCacheProducer
19
20
  PRODUCER_ID = "rbs.constant_type_table"
20
21
 
21
22
  # @param loader [Rigor::Environment::RbsLoader]
22
23
  # @param store [Rigor::Cache::Store]
23
24
  # @return [Hash{String => Rigor::Type}]
24
- def self.fetch(loader:, store:)
25
- descriptor = RbsDescriptor.build(loader)
26
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
27
- compute(loader)
28
- end
29
- end
30
-
31
25
  def self.compute(loader)
32
26
  table = {}
33
27
  loader.each_constant_decl do |name, entry|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rbs_descriptor"
4
+ require_relative "rbs_cache_producer"
4
5
  require_relative "rbs_environment_marshal_patch"
5
6
 
6
7
  module Rigor
@@ -26,19 +27,12 @@ module Rigor
26
27
  # Cache descriptor shape is shared with every other cache
27
28
  # producer that depends on the RBS environment — see
28
29
  # {RbsDescriptor.build}.
29
- class RbsEnvironment
30
+ class RbsEnvironment < RbsCacheProducer
30
31
  PRODUCER_ID = "rbs.environment"
31
32
 
32
33
  # @param loader [Rigor::Environment::RbsLoader]
33
34
  # @param store [Rigor::Cache::Store]
34
35
  # @return [::RBS::Environment]
35
- def self.fetch(loader:, store:)
36
- descriptor = RbsDescriptor.build(loader)
37
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
38
- compute(loader)
39
- end
40
- end
41
-
42
36
  def self.compute(loader)
43
37
  Rigor::Environment::RbsLoader.build_env_for(
44
38
  libraries: loader.libraries,
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rbs_descriptor"
4
+ require_relative "rbs_cache_producer"
4
5
  require_relative "rbs_environment_marshal_patch"
5
6
 
6
7
  module Rigor
@@ -23,19 +24,12 @@ module Rigor
23
24
  #
24
25
  # Marshal-cleanness of `RBS::Definition` is enabled by the
25
26
  # v0.0.9 C2 `RBS::Location` patch.
26
- class RbsInstanceDefinitions
27
+ class RbsInstanceDefinitions < RbsCacheProducer
27
28
  PRODUCER_ID = "rbs.instance_definitions"
28
29
 
29
30
  # @param loader [Rigor::Environment::RbsLoader]
30
31
  # @param store [Rigor::Cache::Store]
31
32
  # @return [Hash{String => RBS::Definition}]
32
- def self.fetch(loader:, store:)
33
- descriptor = RbsDescriptor.build(loader)
34
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
35
- compute(loader)
36
- end
37
- end
38
-
39
33
  def self.compute(loader)
40
34
  table = {}
41
35
  loader.each_known_class_name do |name|
@@ -51,19 +45,12 @@ module Rigor
51
45
  # Singleton-side equivalent of {RbsInstanceDefinitions}.
52
46
  # Caches the full `Hash<String, RBS::Definition>` for the
53
47
  # singleton class of every RBS-known class.
54
- class RbsSingletonDefinitions
48
+ class RbsSingletonDefinitions < RbsCacheProducer
55
49
  PRODUCER_ID = "rbs.singleton_definitions"
56
50
 
57
51
  # @param loader [Rigor::Environment::RbsLoader]
58
52
  # @param store [Rigor::Cache::Store]
59
53
  # @return [Hash{String => RBS::Definition}]
60
- def self.fetch(loader:, store:)
61
- descriptor = RbsDescriptor.build(loader)
62
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
63
- compute(loader)
64
- end
65
- end
66
-
67
54
  def self.compute(loader)
68
55
  table = {}
69
56
  loader.each_known_class_name do |name|