rigortype 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -4,6 +4,8 @@ require "fileutils"
4
4
  require "digest"
5
5
  require "zlib"
6
6
 
7
+ require_relative "engine_source"
8
+
7
9
  module Rigor
8
10
  module Cache
9
11
  # ADR-46 — disk persistence for the incremental analyzer's per-file state, so a `--incremental` session
@@ -14,8 +16,16 @@ module Rigor
14
16
  # this snapshot is loaded UNCONDITIONALLY when the global fingerprint matches — the per-file digests
15
17
  # *inside* it drive the incremental re-analysis decision; they do not gate the load. The fingerprint
16
18
  # captures the inputs whose change requires a full rebuild — the resolved configuration, the RBS
17
- # environment, the engine version but NOT the analyzed source contents. A fingerprint mismatch (config /
18
- # gem / version change) drops the snapshot and forces a full re-analysis, the conservative direction.
19
+ # environment, the engine version and (on a checkout) the engine's own source but NOT the analyzed
20
+ # source contents. A fingerprint mismatch (config / gem / version / engine change) drops the snapshot and
21
+ # forces a full re-analysis, the conservative direction.
22
+ #
23
+ # An engine change drops the WHOLE snapshot rather than part of it, which is a soundness point before it
24
+ # is a simplicity one: every section here except `digests` is a value the analyzer computed, so a changed
25
+ # engine can move any of it — including the dependency edges, where a new engine recording an edge the old
26
+ # one missed would let a recheck skip the very file that needed re-analysing. Retaining the one
27
+ # engine-independent section would not pay either: `digests` is 2.5% of a 2.5 MB snapshot of this repo,
28
+ # and re-deriving it is a file-digest walk costing ~0.2% of the full run it would be saving.
19
29
  #
20
30
  # Every operation is fault-tolerant: a missing, unreadable, schema-mismatched, fingerprint-mismatched, or
21
31
  # corrupt snapshot loads as nil (→ a cold full run), and a write failure is swallowed (→ the next run is
@@ -38,8 +48,11 @@ module Rigor
38
48
  # bundle (the per-def parameter-shape / visibility / ancestry surface the declaration-stability gate
39
49
  # compares) and WD2 adds `return_summaries` (per-def observed-key return descriptors + mutation-effect
40
50
  # 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
51
+ # nil (a clean cold rebuild — no migration). 11: ADR-67 WD6c lift adds `param_table` (the inferred-param
52
+ # seed table the run's diagnostics were computed under, diffed on the next recheck to invalidate a
53
+ # callee whose seeds moved because a caller changed); a pre-11 blob mismatches the SCHEMA gate and
54
+ # loads as nil (a clean cold rebuild — no migration).
55
+ SCHEMA = 11
43
56
 
44
57
  # The persisted per-file state.
45
58
  # `cache` maps an analyzed file to its diagnostics.
@@ -68,20 +81,35 @@ module Rigor
68
81
  # (Marshal-clean type tuples), their `describe(:short)` return descriptors, and the content-mutated
69
82
  # parameter positions. A recheck re-evaluates a declaration-stable changed callee at these keys and,
70
83
  # when every return + the effects are unchanged, skips its symbol dependents.
84
+ # ADR-67 WD6c lift:
85
+ # `param_table` is the `parameter_inference:` seed table (`[class, method, kind] => {param => Type}`)
86
+ # the run that wrote the snapshot seeded its analysis from — `{}` when the gate is off. A recheck
87
+ # recomputes the table fresh (the pre-pass is whole-project by design) and diffs it against this copy;
88
+ # a changed entry invalidates the callee's file and its symbol dependents. The types are Marshal-clean
89
+ # by the session's per-entry filter; a dropped entry re-checks its callee, the conservative direction.
71
90
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
72
91
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
73
92
  :missing, :class_decls, :seed_bundles, :plugin_fact_digest,
74
- :return_summaries)
93
+ :return_summaries, :param_table)
75
94
 
76
95
  # The global fingerprint that gates a snapshot load: a digest of the inputs whose change requires a full
77
- # rebuild — the engine version + schema, the resolved configuration, the analysis **roots** (the path
78
- # arguments, e.g. `["lib"]`, NOT the expanded file list — so a snapshot is keyed to an invocation's roots
79
- # but adding / removing a file under them is handled incrementally by the session, not a full rebuild),
80
- # the resolved gem set (`Gemfile.lock` / `rbs_collection`), and the project's own RBS (`signature_paths`
81
- # file contents). Built WITHOUT constructing the RBS environment so the warm path can gate the load
82
- # cheaply, before the costly env build. The `--verify-incremental` gate is the safety net for any
83
- # under-capture (it would surface as an incremental-vs-full mismatch). Returns nil on any error → the
84
- # caller falls back to a non-persisted run.
96
+ # rebuild — the engine version + schema, the engine's own SOURCE when the version does not pin it, the
97
+ # resolved configuration, the analysis **roots** (the path arguments, e.g. `["lib"]`, NOT the expanded
98
+ # file list so a snapshot is keyed to an invocation's roots but adding / removing a file under them is
99
+ # handled incrementally by the session, not a full rebuild), the resolved gem set (`Gemfile.lock` /
100
+ # `rbs_collection`), and the project's own RBS (`signature_paths` file contents). Built WITHOUT
101
+ # constructing the RBS environment so the warm path can gate the load cheaply, before the costly env
102
+ # build. The `--verify-incremental` gate is the safety net for any under-capture (it would surface as an
103
+ # incremental-vs-full mismatch). Returns nil on any error → the caller falls back to a non-persisted run.
104
+ #
105
+ # Issue #285, wired here by #289 — every value in this snapshot is something the ANALYZER computed, so
106
+ # `Rigor::VERSION` alone is not enough to identify what produced it. A version pins the engine's bytes
107
+ # for a RubyGems install and for nothing else, so on a checkout a warm recheck served diagnostics a
108
+ # pre-edit analyzer had computed: editing `lib/rigor/inference/*.rb` moved no ANALYZED file, the changed
109
+ # set came back empty, and 357 unchanged files replayed their old answers.
110
+ # {EngineSource.process_identity} closes it, and answers nil for a version-pinned tree — which adds no
111
+ # part, so a released gem's fingerprint is byte-identical to the pre-#289 one and its warm snapshots
112
+ # survive the upgrade untouched.
85
113
  def self.fingerprint(configuration:, roots:)
86
114
  parts = [
87
115
  "engine:#{Rigor::VERSION}:#{SCHEMA}",
@@ -91,8 +119,15 @@ module Rigor
91
119
  "rbs_collection:#{digest_file_if_present('rbs_collection.lock.yaml')}",
92
120
  "sig:#{digest_signature_paths(configuration.signature_paths)}"
93
121
  ]
122
+ identity = EngineSource.process_identity
123
+ parts << "engine-source:#{identity}" if identity
94
124
  Digest::SHA256.hexdigest(parts.join("\x00"))
95
125
  rescue StandardError
126
+ # {EngineSource::Unavailable} lands here too, and nil is the answer it requires rather than one it
127
+ # merely tolerates: an engine we cannot identify must DISABLE the snapshot, never fall back to the
128
+ # version-only key that is the blind spot above. Nil does disable it on both sides —
129
+ # {Analysis::IncrementalSession} guards the load AND the save on the fingerprint, so nothing stale is
130
+ # read and no nil-keyed blob is written for the next equally-unidentifiable run to match against.
96
131
  nil
97
132
  end
98
133
 
@@ -124,9 +159,42 @@ module Rigor
124
159
  # The stored {Payload}, or nil when absent / unreadable / schema or fingerprint mismatch / corrupt.
125
160
  # Never raises.
126
161
  def load(fingerprint:)
162
+ data = read_data
163
+ return nil unless data && data[:fingerprint] == fingerprint
164
+
165
+ payload_from(data)
166
+ end
167
+
168
+ # Issue #134 slice 2 — the same load against SEVERAL acceptable fingerprints, reading the blob once.
169
+ # A reader that did not itself write the snapshot cannot know which analysis ROOTS it was written under
170
+ # (`rigor check --incremental lib` and a bare `rigor check --incremental` produce different fingerprints
171
+ # for the same project), and `#load` would have to re-inflate + re-unmarshal the whole blob per candidate.
172
+ # The fingerprint that matched is returned alongside the payload so the caller can mix it into ITS own
173
+ # cache key — the snapshot's identity is exactly what "these dependency edges came from that world" means.
174
+ #
175
+ # @param fingerprints [Array<String>] candidates, most-specific first.
176
+ # @return [Array(String, Payload), nil] `[matched fingerprint, payload]`, or nil on any miss.
177
+ def load_any(fingerprints:)
178
+ data = read_data
179
+ return nil if data.nil?
180
+
181
+ matched = Array(fingerprints).compact.find { |candidate| data[:fingerprint] == candidate }
182
+ return nil if matched.nil?
183
+
184
+ [matched, payload_from(data)]
185
+ end
186
+
187
+ # The raw stored Hash when it is present, readable, and schema-current; nil otherwise. Never raises —
188
+ # a missing, corrupt, or stale-schema snapshot is a cold run, not an error (the ADR-45 invariant).
189
+ def read_data
127
190
  data = Marshal.load(Zlib::Inflate.inflate(File.binread(@path))) # rubocop:disable Security/MarshalLoad
128
- return nil unless data.is_a?(Hash) && data[:schema] == SCHEMA && data[:fingerprint] == fingerprint
191
+ data.is_a?(Hash) && data[:schema] == SCHEMA ? data : nil
192
+ rescue StandardError
193
+ nil
194
+ end
195
+ private :read_data
129
196
 
197
+ def payload_from(data)
130
198
  Payload.new(
131
199
  cache: data[:cache], sources: data[:sources],
132
200
  digests: data[:digests], analyzed: data[:analyzed],
@@ -137,11 +205,11 @@ module Rigor
137
205
  class_decls: data[:class_decls] || {},
138
206
  seed_bundles: data[:seed_bundles] || {},
139
207
  plugin_fact_digest: data[:plugin_fact_digest],
140
- return_summaries: data[:return_summaries] || {}
208
+ return_summaries: data[:return_summaries] || {},
209
+ param_table: data[:param_table] || {}
141
210
  )
142
- rescue StandardError
143
- nil
144
211
  end
212
+ private :payload_from
145
213
 
146
214
  # Persist `payload` under `fingerprint`. Writes via a temp file + atomic rename so a concurrent reader
147
215
  # never sees a half-written snapshot. Returns true on success, false on any failure (never raises).
@@ -158,7 +226,8 @@ module Rigor
158
226
  class_decls: payload.class_decls,
159
227
  seed_bundles: payload.seed_bundles,
160
228
  plugin_fact_digest: payload.plugin_fact_digest,
161
- return_summaries: payload.return_summaries
229
+ return_summaries: payload.return_summaries,
230
+ param_table: payload.param_table
162
231
  )
163
232
  blob = Zlib::Deflate.deflate(raw)
164
233
  tmp = "#{@path}.#{Process.pid}.tmp"
@@ -15,12 +15,22 @@ module Rigor
15
15
  # `self::PRODUCER_ID` resolves the constant on the concrete subclass, and `compute(loader)` dispatches to
16
16
  # its private class method. See the `_CacheProducer` RBS interface for the structural contract.
17
17
  class RbsCacheProducer
18
+ # Every RBS producer is whole-project and content-keyed: one entry is live and any older generation is
19
+ # unreachable, so `Cache::Store#evict!` keeps a small number of them (one spare for the still-warm
20
+ # previous signature state — the RBS environment blob alone runs to ~1.8 MB, ADR-54 WD3). Declared on
21
+ # the base, so a producer added by subclassing inherits a cap instead of being silently uncapped; a
22
+ # subclass with different economics overrides this method.
23
+ def self.generation_cap
24
+ 2
25
+ end
26
+
18
27
  def self.fetch(loader:, store:)
19
28
  # ADR-54 WD4 — the descriptor is identical for every producer consulting the same loader (same sig
20
29
  # files, same libraries), so the loader memoises one build per process instead of re-digesting every
21
30
  # .rbs file once per producer.
22
31
  descriptor = loader.rbs_cache_descriptor
23
- store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor) do
32
+ store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor,
33
+ generation_cap: generation_cap) do
24
34
  compute(loader)
25
35
  end
26
36
  end
@@ -17,6 +17,20 @@ require "rbs"
17
17
  #
18
18
  # Idempotent: the guard checks `method_defined?(:_dump)` so requiring this file twice (or against an upstream
19
19
  # rbs that adds Marshal hooks itself) is a no-op.
20
+ #
21
+ # `RBS::TypeName` / `RBS::Namespace` carry a second, subtler Marshal hazard, introduced by rbs 4.1's
22
+ # flyweight interning (ruby/rbs#2957): both memoise their `#hash` into an `@hash` ivar, and that value is
23
+ # derived from `Array#hash` / `Symbol#hash`, which Ruby seeds PER PROCESS. Marshal round-trips the ivar
24
+ # verbatim, so a cached `TypeName` loaded in a later process answers `hash` with the *writing* process's
25
+ # value while a freshly parsed `TypeName.parse("::String")` answers with this process's. The two are still
26
+ # `eql?`, so nothing raises — every Hash keyed by a TypeName simply misses. `RBS::Environment#class_decls`
27
+ # is exactly such a Hash, so a warm cache silently reported every core class as unknown.
28
+ #
29
+ # `_dump` / `_load` on both classes fixes it at the representation: the name is dumped as its source string
30
+ # and reconstructed through `.parse`, which routes back through the flyweight interner. That drops the stale
31
+ # `@hash` (it is re-memoised on demand, under this process's seed), and restores the flyweight identity the
32
+ # interner exists to provide — a cached env now shares one object per distinct name rather than one per
33
+ # reference. Both classes are value objects fully described by `to_s`, so the round-trip is lossless.
20
34
  module RBS
21
35
  class Location
22
36
  unless method_defined?(:_dump)
@@ -29,4 +43,28 @@ module RBS
29
43
  end
30
44
  end
31
45
  end
46
+
47
+ class Namespace
48
+ unless method_defined?(:_dump)
49
+ def _dump(_)
50
+ to_s
51
+ end
52
+
53
+ def self._load(string)
54
+ parse(string)
55
+ end
56
+ end
57
+ end
58
+
59
+ class TypeName
60
+ unless method_defined?(:_dump)
61
+ def _dump(_)
62
+ to_s
63
+ end
64
+
65
+ def self._load(string)
66
+ parse(string)
67
+ end
68
+ end
69
+ end
32
70
  end
@@ -37,18 +37,10 @@ module Rigor
37
37
  # blob whose class layout still happens to unmarshal.
38
38
  PAYLOAD_ABI_VERSION = Rigor::VERSION
39
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
40
+ # The `generation_cap:` value declaring that a producer keeps MANY live entries under one id at once
41
+ # (per-file and per-plugin producers): a generation count is not a staleness proxy there, so the
42
+ # compaction pass in {#evict!} leaves the producer alone and only the size-based LRU pass can touch it.
43
+ UNBOUNDED_GENERATIONS = :unbounded
52
44
 
53
45
  STALE_TEMP_FILE_AGE_SECONDS = 60 * 60
54
46
 
@@ -76,6 +68,14 @@ module Rigor
76
68
  @misses = 0
77
69
  @writes = 0
78
70
  @by_producer = Hash.new { |h, k| h[k] = { hits: 0, misses: 0, writes: 0 } }
71
+ # `producer_id => generation cap`, populated from the `generation_cap:` every fetch call declares (see
72
+ # {#declare_generation_cap}). This is the bridge between the id STRINGS the fetch API takes and the
73
+ # directory names {#evict!} walks: a producer id the Store never saw declared stays uncapped, which
74
+ # errs toward under-evicting. Per-instance rather than process-global on purpose — a global mutable
75
+ # registry would be a `Ractor` shareability hazard for pool mode, and the Store instance that wrote a
76
+ # generation is the one whose `evict!` compacts it (`CLI::CheckCommand` calls
77
+ # `runner.cache_store&.evict!`).
78
+ @generation_caps = {}
79
79
  # Process-level in-memory layer keyed by `(producer_id, cache_key)`. Avoids the disk read +
80
80
  # `Marshal.load` cost (the dominant share of repeated cache-hit calls per stackprof) when many
81
81
  # short-lived `Analysis::Runner` instances share one `Store` — the spec process, the LSP daemon's
@@ -161,6 +161,12 @@ module Rigor
161
161
  private_class_method :collect_producers
162
162
 
163
163
  # @param producer_id [String] stable cache namespace; only `[a-z][a-z0-9._-]*` is accepted.
164
+ # @param generation_cap [Integer, Symbol] how many generations of this producer survive a compaction
165
+ # pass — a positive `Integer` for a whole-project producer (one live entry, older ones unreachable),
166
+ # or {UNBOUNDED_GENERATIONS} for a producer with many simultaneously-live entries. REQUIRED, and
167
+ # sourced from the producer's own declaration (`RbsCacheProducer.generation_cap`,
168
+ # `RunCacheKey::GENERATION_CAP`, `Plugin::Base.producer generation_cap:`) rather than invented at the
169
+ # call site. See {#evict!}.
164
170
  # @param params [Hash] producer inputs; mixed into the cache key via {Descriptor#cache_key_for}.
165
171
  # @param descriptor [Rigor::Cache::Descriptor] the invalidation descriptor for the value being cached.
166
172
  # @param serialize [#call, nil] optional callable that turns the producer's return value into a binary
@@ -175,9 +181,10 @@ module Rigor
175
181
  # path.
176
182
  # @yieldreturn the value to cache.
177
183
  # @return the cached value (loaded from disk on hit; produced by the block on miss).
178
- def fetch_or_compute(producer_id:, params:, descriptor:,
184
+ def fetch_or_compute(producer_id:, params:, descriptor:, generation_cap:,
179
185
  serialize: nil, deserialize: nil, &block)
180
186
  validate_producer_id!(producer_id)
187
+ declare_generation_cap(producer_id, generation_cap)
181
188
  disk = ensure_schema_version!
182
189
 
183
190
  key = descriptor.cache_key_for(producer_id: producer_id, params: params)
@@ -217,8 +224,12 @@ module Rigor
217
224
  #
218
225
  # The block MUST return `[value, dependency_descriptor]`. Disk reads are not in-process-memoised —
219
226
  # validation always re-checks the filesystem — but a single run only looks up once.
220
- def fetch_or_validate(producer_id:, key_descriptor:, params: {}, serialize: nil, deserialize: nil)
227
+ #
228
+ # `generation_cap:` carries the same producer-declared compaction budget as {#fetch_or_compute}.
229
+ def fetch_or_validate(producer_id:, key_descriptor:, generation_cap:, params: {},
230
+ serialize: nil, deserialize: nil)
221
231
  validate_producer_id!(producer_id)
232
+ declare_generation_cap(producer_id, generation_cap)
222
233
  disk = ensure_schema_version!
223
234
 
224
235
  key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
@@ -243,6 +254,9 @@ module Rigor
243
254
  # to serve a run's diagnostics WITHOUT loading the inference engine — it never runs a producer block, so
244
255
  # there is nothing to write. Records a hit (for `--cache-stats` parity) but never a miss (a probe miss
245
256
  # hands off to the full path, which records its own).
257
+ #
258
+ # Takes no `generation_cap:`: a pure read creates no generation, so a run that only ever peeks has
259
+ # nothing to compact. The write path for the same producer declares the cap.
246
260
  def peek_validated(producer_id:, key_descriptor:, params: {}, deserialize: nil)
247
261
  validate_producer_id!(producer_id)
248
262
  return nil unless ensure_schema_version!
@@ -256,8 +270,15 @@ module Rigor
256
270
  validated[0]
257
271
  end
258
272
 
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
273
+ # ADR-6 § "Eviction" — compaction pass over the on-disk cache. No-op when the store is read-only.
274
+ #
275
+ # The generation cap of pass 2 comes from what the producers themselves declared through this Store's
276
+ # fetch calls (`generation_cap:`), NOT from a maintained table of producer ids: a producer id this
277
+ # Store never saw is left alone. That is deliberately the safe direction — a producer that was not
278
+ # consulted this run also wrote no new generation, so the only entries this can skip are ones that were
279
+ # already sitting there.
280
+ #
281
+ # Stale temp file cleanup and the generation cap run regardless of `max_bytes:` — they reclaim
261
282
  # provably-dead bytes (leaked temp files, unreachable content-keyed generations) rather than enforcing a
262
283
  # size budget, so an explicitly unbounded store (`max_bytes: nil`) still benefits from them. The
263
284
  # size-based LRU pass below stays gated on `max_bytes:` being configured: it walks all remaining
@@ -279,7 +300,7 @@ module Rigor
279
300
  entries.each do |entry|
280
301
  break if total <= @max_bytes
281
302
 
282
- total -= entry[:bytes] if unlink_entry(entry[:path])
303
+ total -= entry[:bytes] if unlink_entry_and_shard?(entry[:path])
283
304
  end
284
305
  nil
285
306
  rescue StandardError
@@ -320,6 +341,37 @@ module Rigor
320
341
  "producer_id must match #{VALID_PRODUCER_ID.inspect}, got #{producer_id.inspect}"
321
342
  end
322
343
 
344
+ # Records the producer's declared compaction budget for {#evict!}. Every fetch call carries it, so a
345
+ # producer cannot reach disk without stating one — the failure mode the previous hardcoded id table
346
+ # had (a new whole-project producer silently uncapped) is not expressible.
347
+ #
348
+ # Two DIFFERENT caps for one producer id in one process is a producer bug, not a policy: whichever the
349
+ # compaction pass picked would be arbitrary. It raises, consistent with the serializer-contract
350
+ # violations {#try_write_entry} deliberately lets through.
351
+ def declare_generation_cap(producer_id, generation_cap)
352
+ validate_generation_cap!(producer_id, generation_cap)
353
+ @monitor.synchronize do
354
+ previous = @generation_caps[producer_id]
355
+ if !previous.nil? && previous != generation_cap
356
+ raise ArgumentError,
357
+ "producer #{producer_id.inspect} declared generation_cap #{generation_cap.inspect} after " \
358
+ "#{previous.inspect}; one producer id must declare one cap"
359
+ end
360
+
361
+ @generation_caps[producer_id] = generation_cap
362
+ end
363
+ end
364
+
365
+ def validate_generation_cap!(producer_id, generation_cap)
366
+ return if generation_cap == UNBOUNDED_GENERATIONS
367
+ return if generation_cap.is_a?(Integer) && generation_cap.positive?
368
+
369
+ raise ArgumentError,
370
+ "producer #{producer_id.inspect} must declare generation_cap as a positive Integer (a " \
371
+ "whole-project producer: how many generations survive compaction) or " \
372
+ "#{UNBOUNDED_GENERATIONS.inspect} (many entries live at once), got #{generation_cap.inspect}"
373
+ end
374
+
323
375
  def entry_path(producer_id, key)
324
376
  File.join(@root, producer_id, key[0, 2], "#{key[2..]}.entry")
325
377
  end
@@ -541,7 +593,7 @@ module Rigor
541
593
  next unless File.file?(path)
542
594
  next if File.mtime(path) > cutoff
543
595
 
544
- unlink_entry(path)
596
+ unlink_entry_and_shard?(path)
545
597
  rescue StandardError
546
598
  next
547
599
  end
@@ -550,16 +602,16 @@ module Rigor
550
602
  end
551
603
 
552
604
  def evict_excess_generations(entries)
605
+ caps = @monitor.synchronize { @generation_caps.dup }
553
606
  removed = {}
554
607
  entries.group_by { |entry| entry[:producer] }.each do |producer, producer_entries|
555
- cap = GENERATION_CAP_BY_PRODUCER[producer]
608
+ cap = caps[producer]
609
+ cap = nil if cap == UNBOUNDED_GENERATIONS
556
610
  next if cap.nil? || producer_entries.size <= cap
557
611
 
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
612
+ excess = producer_entries.sort_by { |entry| [entry[:mtime], entry[:path]] }
613
+ .first(producer_entries.size - cap)
614
+ excess.each { |entry| removed[entry[:path]] = true if unlink_entry_and_shard?(entry[:path]) }
563
615
  end
564
616
  return entries if removed.empty?
565
617
 
@@ -573,6 +625,29 @@ module Rigor
573
625
  false
574
626
  end
575
627
 
628
+ # {#unlink_entry} plus a best-effort {#rmdir_if_empty} of the shard directory the unlinked file left
629
+ # behind. Shared by every eviction/sweep pass so an emptied shard is cleaned up wherever an entry (or
630
+ # stale temp file) is removed, per the issue #216 fossil fix.
631
+ def unlink_entry_and_shard?(path)
632
+ return false unless unlink_entry(path)
633
+
634
+ rmdir_if_empty(File.dirname(path))
635
+ true
636
+ end
637
+
638
+ # Removes `dir` — a shard directory (`entry_path`'s `key[0, 2]` component) — when the unlink that
639
+ # just preceded this call was the one to empty it. Best-effort and rescued the same way
640
+ # {#unlink_entry} is: a concurrent writer's `FileUtils.mkdir_p` recreating the shard between the
641
+ # unlink and this call (`Errno::ENOTEMPTY`) or another pass already having removed it
642
+ # (`Errno::ENOENT`) are both benign outcomes, never a reason to break the eviction/sweep pass. This
643
+ # is purely cosmetic (inode reclaim) — it never decides which ENTRIES are evicted, only tidies the
644
+ # directory left behind once they are gone.
645
+ def rmdir_if_empty(dir)
646
+ Dir.rmdir(dir)
647
+ rescue StandardError
648
+ nil
649
+ end
650
+
576
651
  # Returns an array of `{ path:, producer:, mtime:, bytes: }` hashes for every `.entry` file under the
577
652
  # cache root, skipping unreadable entries.
578
653
  def collect_entry_stats
@@ -48,9 +48,6 @@ module Rigor
48
48
 
49
49
  configuration = load_check_configuration(options)
50
50
  configuration = apply_bleeding_edge_override(configuration, options)
51
- conflict = parameter_inference_incremental_conflict(configuration, options)
52
- return conflict unless conflict.nil?
53
-
54
51
  config_warnings = warn_unresolved_config(configuration)
55
52
  cache_root = configuration.cache_path
56
53
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
@@ -62,14 +59,15 @@ module Rigor
62
59
  return finalize_cache_hit(probed, configuration, options, config_warnings) unless probed.nil?
63
60
 
64
61
  load_check_dependencies
65
- special = dispatch_special_check_mode(configuration, options, cache_root)
62
+ special = dispatch_special_check_mode(configuration, options, cache_root, buffer)
66
63
  return special unless special.nil?
67
64
 
68
- runner = build_check_runner(
65
+ invocation = invoke_check(
69
66
  configuration: configuration, options: options,
70
67
  buffer: buffer, cache_root: cache_root
71
68
  )
72
- raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
69
+ runner = invocation.runner
70
+ raw_result = invocation.result
73
71
  result = apply_baseline_filter(raw_result, configuration, options)
74
72
 
75
73
  coverage = compute_coverage(runner, configuration, options)
@@ -142,30 +140,24 @@ module Rigor
142
140
  exit_code
143
141
  end
144
142
 
145
- # ADR-67 WD6c — `parameter_inference:` and the ADR-46 incremental modes are mutually exclusive in slice 1.
146
- # The parameter table introduces cross-file edges (a caller's argument types drive a callee's body
147
- # diagnostics) that the per-file dependency recorder does not yet carry, so a cached file whose parameter
148
- # seeds changed would serve a stale result. Refuse with a message naming the ADR-46 gap rather than
149
- # silently producing unsound incremental diagnostics; the edge wiring is the named follow-up. Returns the
150
- # usage exit code on a conflict, or nil to proceed. `--verify-incremental` (the acceptance gate that runs
151
- # incremental analysis) is caught here too.
152
- def parameter_inference_incremental_conflict(configuration, options)
153
- return nil unless configuration.parameter_inference
154
- return nil unless options.fetch(:incremental) || options.fetch(:verify_incremental)
155
-
156
- flag = options.fetch(:incremental) ? "--incremental" : "--verify-incremental"
157
- @err.puts("rigor: parameter_inference: cannot combine with #{flag} — the call-site parameter table " \
158
- "introduces cross-file caller→callee edges the ADR-46 incremental dependency graph does not " \
159
- "yet record, so a cached file could serve a stale diagnostic. Run a full check (drop " \
160
- "#{flag}), or disable parameter_inference: for this run.")
161
- CLI::EXIT_USAGE
162
- end
163
-
164
143
  # ADR-46 — the two incremental-analysis check modes both fully handle the run and return an exit code (so `run`
165
144
  # short-circuits); returns nil for an ordinary check.
166
- def dispatch_special_check_mode(configuration, options, cache_root)
167
- return run_verify_incremental(configuration, options, cache_root) if options.fetch(:verify_incremental)
168
- return run_incremental_check(configuration, options, cache_root) if options.fetch(:incremental)
145
+ # A nil return means "not a special mode" — `run` continues with the ordinary check. Editor mode option B
146
+ # also returns nil when it declines (no reusable snapshot), so the run falls back to single-file scope.
147
+ def dispatch_special_check_mode(configuration, options, cache_root, buffer)
148
+ if options.fetch(:verify_incremental)
149
+ # The gate compares an incremental recheck against a full-run oracle, and a buffer makes the two
150
+ # disagree by construction (the oracle reads the file on disk). Refusing beats the silent wrong
151
+ # answer both incremental modes gave a buffer before #146.
152
+ if buffer
153
+ @err.puts("rigor: --verify-incremental cannot run against an editor buffer " \
154
+ "(--tmp-file / --instead-of); it compares against a full analysis of the files on disk.")
155
+ return CLI::EXIT_USAGE
156
+ end
157
+
158
+ return run_verify_incremental(configuration, options, cache_root)
159
+ end
160
+ return run_incremental_check(configuration, options, cache_root, buffer) if options.fetch(:incremental)
169
161
 
170
162
  nil
171
163
  end
@@ -211,7 +203,7 @@ module Rigor
211
203
  # run (plus their dependents), serving the rest from the snapshot; on a miss runs a full baseline. Persists the
212
204
  # updated snapshot for the next invocation. Diagnostics are identical to a full run (the `--verify-incremental`
213
205
  # gate enforces this); the win is skipping per-file inference for unchanged files.
214
- def run_incremental_check(configuration, options, cache_root)
206
+ def run_incremental_check(configuration, options, cache_root, buffer = nil)
215
207
  require_relative "check_runner_factory"
216
208
  paths = @argv.empty? ? nil : @argv
217
209
  fingerprint = Cache::IncrementalSnapshot.fingerprint(
@@ -225,9 +217,12 @@ module Rigor
225
217
  # ADR-46 — thread the same worker-count precedence the standard `check` path uses
226
218
  # (CLI `--workers` > `RIGOR_RACTOR_WORKERS` > `parallel.workers:` > 0) so the recheck's closure
227
219
  # re-analysis parallelises; the fork pool marshals dependency records back so the graph is sound.
228
- workers: CheckRunnerFactory.resolve_workers(options, configuration)
220
+ workers: CheckRunnerFactory.resolve_workers(options, configuration),
221
+ buffer: buffer
229
222
  )
230
223
 
224
+ return run_editor_mode_option_b(session, snapshot, fingerprint, configuration, options) if buffer
225
+
231
226
  diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
232
227
  # The banner's file count comes from the session's analyzed set (cold analyses all; a warm recheck's
233
228
  # `@analyzed` advances to the current file set), so a dedicated probe Runner + `Dir.glob` is no longer
@@ -243,6 +238,33 @@ module Rigor
243
238
  result.success? ? 0 : 1
244
239
  end
245
240
 
241
+ # Editor mode option B (#146) — `--incremental` plus an editor buffer. The whole project is in scope with
242
+ # the buffer substituted for one file: the buffer's logical path and its dependents re-analyse, every
243
+ # other file is served from the snapshot. Before this, the two flags together silently ignored the
244
+ # buffer and analysed the file on disk, which is a wrong answer rather than a missing feature.
245
+ #
246
+ # The session never saves — the buffer's bytes exist only in the editor — so there is no warm state to
247
+ # build up. That is why a snapshot it cannot reuse means falling back to option A (single-file scope,
248
+ # the pre-#146 behaviour) instead of running a baseline that would repeat on the next keystroke. The
249
+ # note tells the user how to get option B: warm the snapshot with a plain `rigor check --incremental`.
250
+ def run_editor_mode_option_b(session, snapshot, fingerprint, configuration, options)
251
+ result = session.run_buffer_recheck(snapshot: snapshot, fingerprint: fingerprint)
252
+ if result.nil?
253
+ @err.puts("rigor: --incremental has no reusable snapshot for this project; analysing the buffer " \
254
+ "alone (run `rigor check --incremental` once to enable whole-project editor mode).")
255
+ return nil
256
+ end
257
+
258
+ @err.puts("rigor: --incremental editor mode — re-analysed #{result.affected.size} file(s), " \
259
+ "#{result.reused.size} served from cache")
260
+ emit_incremental_fact_surface_notes(session)
261
+ filtered = apply_baseline_filter(
262
+ Analysis::Result.new(diagnostics: result.diagnostics, stats: nil), configuration, options
263
+ )
264
+ write_result(filtered, options.fetch(:format))
265
+ filtered.success? ? 0 : 1
266
+ end
267
+
246
268
  # ADR-88 WD1 — a one-line stderr note when the plugin fact surface (an ADR-9 fact, an ADR-60 producer
247
269
  # value, or an `incremental_state_fingerprint` hook) forced a full run: either it CHANGED since the
248
270
  # snapshot (a Sorbet sig edit, a schema change) or a contributing plugin declares NO fingerprint surface
@@ -383,11 +405,16 @@ module Rigor
383
405
  @err.puts("rigor: #{silenced_count} diagnostic(s) silenced by baseline #{baseline_path}")
384
406
  end
385
407
 
386
- def build_check_runner(configuration:, options:, buffer:, cache_root:)
387
- require_relative "check_runner_factory"
388
- CheckRunnerFactory.build(
408
+ # The primary check run, routed through the shared {CheckInvocation} entry point so `rigor doctor` and
409
+ # `rigor skill describe --deep` reach a result through this exact path rather than re-deriving it (#148).
410
+ # Required lazily for the reason the old inline `CheckRunnerFactory.build` was: it pulls the inference engine,
411
+ # and ADR-87 WD4's cache-hit fast path must reach its verdict without it.
412
+ def invoke_check(configuration:, options:, buffer:, cache_root:)
413
+ require_relative "check_invocation"
414
+ CheckInvocation.run(
389
415
  configuration: configuration, options: options,
390
- buffer: buffer, cache_root: cache_root
416
+ buffer: buffer, cache_root: cache_root,
417
+ paths: @argv.empty? ? nil : @argv
391
418
  )
392
419
  end
393
420