rigortype 0.3.1 → 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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. metadata +17 -1
@@ -11,29 +11,86 @@ module Rigor
11
11
  #
12
12
  # The WD2 foundation slice wired the surface end-to-end (`bleeding_edge:` config, the
13
13
  # `rigor show-bleedingedge` command, the severity-composition hook in
14
- # {Configuration::SeverityProfile.resolve}), so a discipline lands as a single {FEATURES}
15
- # entry with no engine plumbing — as the first one, `reject-unparseable-signatures`, does.
14
+ # {Configuration::SeverityProfile.resolve}), so a *severity* discipline lands as a single
15
+ # {FEATURES} entry with no engine plumbing — as the first one,
16
+ # `reject-unparseable-signatures`, does.
17
+ #
18
+ # A queued change whose effect is not a severity move — a measurement, an algorithm, or a
19
+ # default that changes while every rule keeps its severity — is a `:behaviour` feature
20
+ # instead. It carries no severity map; its call sites ask
21
+ # {Configuration#bleeding_edge_active?} whether the id is adopted for the run.
22
+ #
23
+ # A `:behaviour` feature MUST NOT change the output of `rigor check` analysis unless its
24
+ # feature id is folded into the analysis-cache identity. Rationale, verified 2026-08-01:
25
+ # severity features are safe because severity is stamped POST-cache — {Analysis::SeverityStamp}
26
+ # (ADR-87 WD4) stores the authored severity and applies the profile + bleeding-edge overrides
27
+ # identically on the miss path and the warm-hit path, so a warm HIT re-resolves under the
28
+ # current selector. A behaviour feature that altered analysis results themselves would poison
29
+ # warm caches across selector changes, because the selector is not part of the cache key. The
30
+ # two queued consumers (#253, #254) change what a separate command measures, not what `check`
31
+ # analyses, so neither is affected.
16
32
  #
17
33
  # Each feature carries a **stable feature id** — part of the ADR-50 WD1 contract
18
34
  # vocabulary: the config, the `show` command, and the eventual CHANGELOG migration note all
19
- # name the same id, and a feature graduates to default-on at a major by being removed from
20
- # {FEATURES}.
35
+ # name the same id, and a feature graduates to default-on at a major (ADR-50 § WD7) by
36
+ # moving from {FEATURES} to {GRADUATED}.
21
37
  module BleedingEdge
38
+ # The two kinds a queued change can take. `:severity` composes through
39
+ # {Configuration::SeverityProfile.resolve}; `:behaviour` is read at a call site through
40
+ # {Configuration#bleeding_edge_active?}.
41
+ KINDS = %i[severity behaviour].freeze
42
+
43
+ # The severity map a `:behaviour` feature carries: none.
44
+ NO_SEVERITY_OVERRIDES = {}.freeze
45
+
22
46
  # One queued change.
23
47
  #
48
+ # The two kinds are exclusive by construction: a `:severity` feature MUST name at least
49
+ # one rule, and a `:behaviour` feature MUST name none — a behaviour switch that also
50
+ # moved a severity would be two changes wearing one id, and the id is what a CHANGELOG
51
+ # migration note and a user's `bleeding_edge:` list both key on.
52
+ #
24
53
  # @!attribute id
25
54
  # @return [String] the stable feature id (contract vocabulary).
26
55
  # @!attribute summary
27
- # @return [String] a one-line description of what it changes.
56
+ # @return [String] a one-line description of what it changes. For a `:behaviour`
57
+ # feature this is the *whole* explanation — there is no severity diff to read.
58
+ # @!attribute kind
59
+ # @return [Symbol] one of {KINDS}.
28
60
  # @!attribute severity_overrides
29
61
  # @return [Hash{String => Symbol}] canonical rule id → the severity this feature
30
62
  # imposes. Composed *below* the user's own `severity_overrides:` and *above* the
31
- # active `severity_profile` (see {Configuration::SeverityProfile.resolve}).
32
- Feature = Data.define(:id, :summary, :severity_overrides) do
63
+ # active `severity_profile` (see {Configuration::SeverityProfile.resolve}). Empty for
64
+ # a `:behaviour` feature.
65
+ Feature = Data.define(:id, :summary, :kind, :severity_overrides) do
66
+ def initialize(id:, summary:, kind:, severity_overrides: NO_SEVERITY_OVERRIDES)
67
+ raise ArgumentError, "kind must be one of #{KINDS.inspect}, got #{kind.inspect}" unless KINDS.include?(kind)
68
+
69
+ if kind == :severity && severity_overrides.empty?
70
+ raise ArgumentError, "bleeding-edge feature #{id.inspect} is :severity but overrides no rule"
71
+ end
72
+ if kind == :behaviour && !severity_overrides.empty?
73
+ raise ArgumentError, "bleeding-edge feature #{id.inspect} is :behaviour but carries severity_overrides"
74
+ end
75
+
76
+ super
77
+ end
78
+
79
+ # @return [Boolean]
80
+ def severity?
81
+ kind == :severity
82
+ end
83
+
84
+ # @return [Boolean]
85
+ def behaviour?
86
+ kind == :behaviour
87
+ end
88
+
33
89
  def to_h
34
90
  {
35
91
  "id" => id,
36
92
  "summary" => summary,
93
+ "kind" => kind.to_s,
37
94
  "severity_overrides" => severity_overrides.transform_values(&:to_s)
38
95
  }
39
96
  end
@@ -48,6 +105,7 @@ module Rigor
48
105
  FEATURES = [
49
106
  Feature.new(
50
107
  id: "reject-unparseable-signatures",
108
+ kind: :severity,
51
109
  summary: "A broken `signature_paths:` RBS set fails the run instead of degrading it silently. An " \
52
110
  "unparseable `.rbs` is otherwise skipped with a warning, and a duplicate-declaration " \
53
111
  "conflict (a file that parses fine but collides on resolve — typically against Rigor's " \
@@ -61,6 +119,7 @@ module Rigor
61
119
  ),
62
120
  Feature.new(
63
121
  id: "use-of-void-value",
122
+ kind: :severity,
64
123
  summary: "Using a value recovered from an author-declared `-> void` return in value context (an " \
65
124
  "assignment right-hand side, a call receiver, or an argument) becomes a `:warning`. An " \
66
125
  "explicit `-> void` is the strongest possible \"do not rely on this return\" signal, so " \
@@ -70,9 +129,51 @@ module Rigor
70
129
  severity_overrides: {
71
130
  "static.value-use.void" => :warning
72
131
  }.freeze
132
+ ),
133
+ Feature.new(
134
+ id: "discovery-seeded-mutation-sites",
135
+ kind: :behaviour,
136
+ summary: "`rigor coverage --protection --mutation` (Tier 2) measures against the same cross-file " \
137
+ "project discovery Tier 1 already seeds, instead of an empty scope — both when picking the " \
138
+ "sites and when re-analysing each breakage to decide whether it was caught. A call whose " \
139
+ "receiver is a project class declared in a *sibling* file (`Post.where`, " \
140
+ "`Rigor::Protection::Mutator.new`) then resolves to the type it really has rather than " \
141
+ "`Dynamic`, so the site is measured instead of dropped — and a breakage there can actually " \
142
+ "be caught. This makes the two tiers judge a site " \
143
+ "by one standard, but it ADDS sites to the denominator, so the reported effectiveness ratio " \
144
+ "goes DOWN on the same code — and `--threshold=RATIO` exits 1 when that ratio falls below a " \
145
+ "number pinned in CI. Off by default for that reason: it is a queued change for the next " \
146
+ "major, not a fix you should be opted into mid-release."
147
+ ),
148
+ Feature.new(
149
+ id: "dependent-closure-kill-oracle",
150
+ kind: :behaviour,
151
+ summary: "`rigor coverage --protection --mutation` (Tier 2) decides a breakage was caught when the " \
152
+ "diagnostic appears anywhere in the mutated file OR the files that depend on it, instead of " \
153
+ "in the mutated file alone. Changing what a method returns is caught in its *callers* — the " \
154
+ "cross-file reach the analyzer exists for — and that catch is scored as a miss today. The " \
155
+ "measurement re-analyses the dependent closure (ADR-46's dependency graph) against the " \
156
+ "mutated bytes, so those catches count. It can only ADD kills, never remove one, so the " \
157
+ "reported ratio moves up or not at all; a recorded ratio nonetheless stops being comparable " \
158
+ "with one measured without it. It costs roughly a third more wall time per mutant, and on " \
159
+ "the two corpora measured so far (Rigor's own `lib`, redmine `app/models`) it added no " \
160
+ "kills — every surviving breakage there is one the analyzer reports nowhere at all, not one " \
161
+ "it reports in a caller."
73
162
  )
74
163
  ].freeze
75
164
 
165
+ # ADR-50 § WD7 — the ids that have already graduated to default-on.
166
+ #
167
+ # A feature graduates at a major by moving *here* from {FEATURES} rather than by simply
168
+ # disappearing: {Configuration#bleeding_edge_active?} then answers an unconditional `true`
169
+ # for the id, so a call site still asking about it keeps the graduated behaviour and gate
170
+ # cleanup can lag graduation by as many releases as it takes. The id also stays in the
171
+ # contract vocabulary the CHANGELOG migration note keys on. Entries are removed only once
172
+ # no call site names them.
173
+ #
174
+ # @return [Array<String>]
175
+ GRADUATED = [].freeze
176
+
76
177
  module_function
77
178
 
78
179
  # @return [Array<Feature>] the whole overlay.
@@ -91,6 +192,19 @@ module Rigor
91
192
  FEATURES.find { |f| f.id == id }
92
193
  end
93
194
 
195
+ # @param id [String]
196
+ # @return [Boolean] whether the id has graduated to default-on ({GRADUATED}).
197
+ def graduated?(id)
198
+ GRADUATED.include?(id)
199
+ end
200
+
201
+ # @param id [String]
202
+ # @return [Boolean] whether the id names a feature this gem knows at all — queued or
203
+ # graduated. Distinct from "adopted"; see {Configuration#bleeding_edge_active?}.
204
+ def known_id?(id)
205
+ graduated?(id) || FEATURES.any? { |f| f.id == id }
206
+ end
207
+
94
208
  # Resolves a normalized `bleeding_edge:` selector (see {Configuration#bleeding_edge}) to
95
209
  # the active {Feature} list. Unknown ids in a `list` / `except` selector are simply
96
210
  # absent from the overlay and contribute nothing — symmetric with how
@@ -125,6 +239,17 @@ module Rigor
125
239
  end.freeze
126
240
  end
127
241
 
242
+ # The ids the active features expose to {Configuration#bleeding_edge_active?}, as a frozen
243
+ # `Set` so a call site on the hot path pays a hash lookup rather than an Array scan.
244
+ # Precomputed once per Configuration; frozen (with frozen members) so the carrier stays
245
+ # `Ractor.shareable?` across the worker boundary.
246
+ #
247
+ # @param selector [Hash] see {#active_features}.
248
+ # @return [Set<String>]
249
+ def active_ids_for(selector)
250
+ Set.new(active_features(selector).map(&:id)).freeze
251
+ end
252
+
128
253
  # Feature ids named by a selector that are NOT in the overlay (typo / graduated / from a
129
254
  # newer gem). Surfaced by `rigor show-bleedingedge` as a hint; never an error.
130
255
  #
@@ -33,7 +33,12 @@ module Rigor
33
33
  # content digests. Old entries must read as misses so the first writable run rebuilds them in the new
34
34
  # format for a clean one-shot migration (the #57 marker discipline: the bump clears the root and
35
35
  # reclaims the unreadable bytes).
36
- SCHEMA_VERSION = 5
36
+ # v6: #237 — `append_stub_declarations` now emits the declaration KIND each referenced-type stub needs
37
+ # (`interface` / `type` / `module` / `class`) and validates each declaration on its own, so a project
38
+ # whose RBS dangles an interface or type-alias reference gets stubs where an older Rigor discarded the
39
+ # whole batch and cached an env in which those signatures are inert. Same reasoning as v3: the
40
+ # marshalled env is the cached value, so it MUST be rebuilt for the fix to take effect.
41
+ SCHEMA_VERSION = 6
37
42
 
38
43
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
39
44
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "../version"
6
+
7
+ module Rigor
8
+ module Cache
9
+ # Issue #285 — the identity of the ENGINE'S OWN SOURCE, as a cache-key slot.
10
+ #
11
+ # Every Rigor cache whose value is a function of what the analyzer COMPUTES (the ADR-45 run-result
12
+ # cache, the #134 mutation-result cache) keys on `Rigor::VERSION`. For a gem installed from RubyGems
13
+ # that is exact — the version pins the bytes. For anyone running an edited working tree it is not, and
14
+ # the failure is invisible: a warm `rigor check` replays the pre-edit diagnostics verbatim, so a
15
+ # before/after measurement of an engine change reports `0 new, 0 gone` no matter what the change does.
16
+ # That shape of false zero survived into #152's FP evaluation before it was caught.
17
+ #
18
+ # ## The two regimes
19
+ #
20
+ # {.identity} answers `nil` for a tree whose identity `Rigor::VERSION` already fixes — a RubyGems
21
+ # install, recognised by its `<gem_home>/gems/rigortype-<VERSION>` directory layout. The caller then
22
+ # adds NO slot, so a released gem's cache key is byte-identical to the one it had before this module
23
+ # existed and pays not one syscall for it. Everything else — a contributor's checkout, a `bundle add
24
+ # rigor, github:` clone (where two commits share one `Rigor::VERSION`), a `path:` gem — is treated as
25
+ # mutable and gets a content digest of the engine's source tree.
26
+ #
27
+ # The predicate is deliberately POSITIVE about being pinned, so every case it fails to recognise lands
28
+ # on the safe side: an unrecognised release pays one directory walk and one extra cache generation,
29
+ # while an unrecognised checkout would have served a stale answer.
30
+ #
31
+ # ## Why a content digest and not a stat tuple
32
+ #
33
+ # A `(size, mtime, ctime, inode)` walk is ~3× cheaper (measured: 5.4 ms vs 17 ms over this repo's 539
34
+ # engine files), and ADR-87 WD1 trusts exactly that tuple for FRESHNESS. It cannot be used here:
35
+ # {Descriptor::FileEntry} states the rule directly — a stat tuple carries machine-local, per-run
36
+ # nondeterministic data and MUST NOT enter a descriptor used as a cache KEY. On the freshness side a
37
+ # moved tuple falls back to the recorded digest, so a fresh checkout of unchanged content still
38
+ # validates; a KEY has no such fallback, so keying on stat would make every CI run and every branch
39
+ # switch a total miss for the `github:`-tracking users this module exists to protect.
40
+ #
41
+ # ## Never a wrong hit
42
+ #
43
+ # A mutable tree whose digest cannot be computed raises {Unavailable} rather than answering `nil`.
44
+ # Both callers already rescue a malformed key into "no cache for this run", which is the only sound
45
+ # reading: falling back to the version-only key would restore precisely the blind spot being fixed.
46
+ module EngineSource
47
+ # Raised when the tree is not version-pinned AND its source cannot be digested. Deliberately not
48
+ # rescued in this module — see the class doc's last paragraph.
49
+ class Unavailable < StandardError; end
50
+
51
+ # The RubyGems package name. `<name>-<version>` is the install directory layout {.version_pinned?}
52
+ # recognises; the constant is not read from the gemspec because this file loads on the boot-slimming
53
+ # probe path, which must not touch RubyGems' specification machinery.
54
+ GEM_NAME = "rigortype"
55
+
56
+ # Engine source, relative to the gem root. `plugins/` ships inside the same gem and its recognisers
57
+ # move diagnostics exactly as `lib/` does, so an edit there must invalidate too; `examples/`,
58
+ # `apps/` and `tool/` are not loaded by an analysis and stay out.
59
+ SOURCE_DIRECTORIES = %w[lib plugins].freeze
60
+
61
+ # The one directory whose absence means "this is not an engine tree". `plugins/` may legitimately be
62
+ # missing (a slimmed install contributes nothing); a missing `lib/` would silently shrink the digest
63
+ # to whatever else happened to be there, which is exactly the weakening this module forbids.
64
+ REQUIRED_DIRECTORY = "lib"
65
+
66
+ module_function
67
+
68
+ # @return [String] the gem root — the directory holding `lib/`, three levels above this file.
69
+ def root
70
+ @root ||= File.expand_path("../../..", __dir__)
71
+ end
72
+
73
+ # @param root [String] the gem root, defaulted through {.root} so a spec can relocate the tree.
74
+ # @return [String, nil] a digest identifying the engine's current source, or nil when the tree is
75
+ # version-pinned and the caller should add no slot at all.
76
+ # @raise [Unavailable] when a mutable tree's source cannot be read.
77
+ def identity(root = self.root)
78
+ return nil if version_pinned?(root)
79
+
80
+ digest_tree(root)
81
+ end
82
+
83
+ # {.identity} for THIS process's engine, computed once. Every production caller wants this; the
84
+ # uncached {.identity} stays the computation, and the seam a spec relocates.
85
+ #
86
+ # A memo, not a per-call walk, on two grounds.
87
+ #
88
+ # Cost. #285 could afford the walk because the two callers it wired reached it at most twice per
89
+ # process. Adding {IncrementalSnapshot.fingerprint} (#289) breaks that: {Protection::MutationCache}
90
+ # builds a fingerprint per snapshot-root candidate, so one `rigor coverage --protection --mutation
91
+ # PATH` reaches this five times — 90 ms of walking for a value that cannot differ between the calls.
92
+ #
93
+ # Correctness, which is the stronger reason. The digest exists to identify the engine that COMPUTED a
94
+ # cached value, and that engine is fixed when the process finishes requiring — nothing an edit does to
95
+ # `lib/` mid-run changes the code already running. Re-reading the tree would eventually key values
96
+ # against source that never produced them, so the memo is the more faithful answer, not merely the
97
+ # cheaper one. That is also why a fork-pool worker inheriting it is right: `PoolCoordinator` builds one
98
+ # session on the parent and forks children that copy-on-write inherit its image, so parent and child
99
+ # run the same engine by construction — and the fingerprints are computed on the parent before a pool
100
+ # exists at all. The same reading covers the long-running `rigor lsp` process.
101
+ #
102
+ # {Unavailable} propagates and is deliberately NOT memoised: the ivar is only assigned on success.
103
+ def process_identity
104
+ return @process_identity if defined?(@process_identity)
105
+
106
+ @process_identity = identity
107
+ end
108
+
109
+ # Discards the {.process_identity} memo; production code MUST NOT call this — a run that recomputed
110
+ # mid-flight would key cached values against source that did not compute them, which is the whole
111
+ # argument for the memo. It exists because a spec process is many logical "processes", and a memo that
112
+ # outlived one example would silently ignore the next one's stub of {.root} / {.identity} and pass for
113
+ # the wrong reason. `spec_helper` calls it before every example so no spec has to know it is here.
114
+ def reset_process_identity!
115
+ remove_instance_variable(:@process_identity) if defined?(@process_identity)
116
+ end
117
+
118
+ # True when `Rigor::VERSION` already pins this tree's bytes: an immutable RubyGems install, laid out
119
+ # as `…/gems/rigortype-<VERSION>`. The `.git` probe is the belt to that braces — a working tree that
120
+ # somehow occupies a release-shaped path is still a working tree.
121
+ def version_pinned?(root)
122
+ return false unless File.basename(root) == "#{GEM_NAME}-#{Rigor::VERSION}"
123
+ return false unless File.basename(File.dirname(root)) == "gems"
124
+
125
+ !File.exist?(File.join(root, ".git"))
126
+ end
127
+
128
+ # A SHA-256 over every engine `.rb` file: its ROOT-RELATIVE path (so the digest survives moving or
129
+ # re-cloning the checkout) followed by its bytes, in sorted path order.
130
+ #
131
+ # The walk itself is not memoised — {.process_identity} is where a production caller gets the
132
+ # once-per-process value, and this stays the computation so a spec can point it at another tree.
133
+ def digest_tree(root)
134
+ unless File.directory?(File.join(root, REQUIRED_DIRECTORY))
135
+ raise Unavailable, "#{root} has no #{REQUIRED_DIRECTORY}/ to identify the engine by"
136
+ end
137
+
138
+ digest = Digest::SHA256.new
139
+ prefix = "#{root}#{File::SEPARATOR}"
140
+ count = 0
141
+ source_files(root).each do |path|
142
+ digest << path.delete_prefix(prefix) << "\0"
143
+ digest.file(path)
144
+ count += 1
145
+ end
146
+ raise Unavailable, "no engine source found under #{root}" if count.zero?
147
+
148
+ digest.hexdigest
149
+ rescue SystemCallError, IOError => e
150
+ raise Unavailable, "engine source under #{root} could not be read: #{e.message}"
151
+ end
152
+
153
+ # Sorted absolute paths of every engine `.rb` file, across the directories that exist.
154
+ def source_files(root)
155
+ SOURCE_DIRECTORIES.flat_map do |relative|
156
+ directory = File.join(root, relative)
157
+ File.directory?(directory) ? Dir.glob(File.join(directory, "**", "*.rb")) : []
158
+ end.sort
159
+ end
160
+ end
161
+ end
162
+ end
@@ -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"