rigortype 0.4.0 → 0.4.1

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/08-understanding-errors.md +3 -2
  4. data/docs/install.md +87 -15
  5. data/docs/manual/02-cli-reference.md +2 -2
  6. data/docs/manual/03-configuration.md +5 -0
  7. data/docs/manual/08-skills.md +4 -3
  8. data/docs/manual/12-caching.md +43 -4
  9. data/docs/manual/14-rails-quickstart.md +19 -11
  10. data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
  11. data/lib/rigor/analysis/check_rules.rb +118 -87
  12. data/lib/rigor/analysis/dependency_recorder.rb +200 -18
  13. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  14. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  15. data/lib/rigor/analysis/incremental_session.rb +273 -49
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  17. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  18. data/lib/rigor/analysis/run_cache_key.rb +27 -8
  19. data/lib/rigor/analysis/run_stats.rb +2 -2
  20. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
  21. data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
  22. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  23. data/lib/rigor/analysis/runner.rb +337 -46
  24. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  25. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  26. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  27. data/lib/rigor/analysis/template_units.rb +15 -5
  28. data/lib/rigor/analysis/worker_session.rb +4 -3
  29. data/lib/rigor/cache/descriptor.rb +40 -1
  30. data/lib/rigor/cache/file_digest.rb +28 -0
  31. data/lib/rigor/cache/incremental_snapshot.rb +70 -8
  32. data/lib/rigor/cache/store.rb +63 -3
  33. data/lib/rigor/cache/value_digest.rb +325 -0
  34. data/lib/rigor/cli/check_command.rb +83 -13
  35. data/lib/rigor/cli/doctor_command.rb +1 -1
  36. data/lib/rigor/cli/effects_report.rb +2 -2
  37. data/lib/rigor/cli/unused_command.rb +3 -3
  38. data/lib/rigor/cli.rb +1 -1
  39. data/lib/rigor/config_audit.rb +3 -2
  40. data/lib/rigor/config_path.rb +29 -0
  41. data/lib/rigor/configuration/severity_profile.rb +5 -0
  42. data/lib/rigor/configuration.rb +6 -5
  43. data/lib/rigor/effects/plugin_facts.rb +6 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  45. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  46. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  47. data/lib/rigor/environment/rbs_loader.rb +1 -1
  48. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  49. data/lib/rigor/environment.rb +13 -70
  50. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  51. data/lib/rigor/inference/captured_locals.rb +7 -1
  52. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  53. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  54. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  55. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  56. data/lib/rigor/inference/declaration_walk.rb +7 -0
  57. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  58. data/lib/rigor/inference/definer_resolution.rb +266 -0
  59. data/lib/rigor/inference/expression_typer.rb +136 -45
  60. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  61. data/lib/rigor/inference/guard_rebinding.rb +53 -12
  62. data/lib/rigor/inference/macro_block_self_type.rb +14 -2
  63. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
  64. data/lib/rigor/inference/module_function_state.rb +213 -0
  65. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  66. data/lib/rigor/inference/narrowing.rb +147 -17
  67. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  68. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  69. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  70. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  71. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  72. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  73. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  74. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  75. data/lib/rigor/inference/scope_indexer.rb +1443 -295
  76. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  77. data/lib/rigor/inference/statement_evaluator.rb +80 -14
  78. data/lib/rigor/plugin/base.rb +28 -8
  79. data/lib/rigor/plugin/io_boundary.rb +82 -4
  80. data/lib/rigor/plugin/isolation.rb +1 -1
  81. data/lib/rigor/plugin/trust_policy.rb +3 -3
  82. data/lib/rigor/protection/discovery_seed.rb +18 -9
  83. data/lib/rigor/protection/mutation_cache.rb +3 -3
  84. data/lib/rigor/reflection/constant_ancestors.rb +96 -47
  85. data/lib/rigor/reflection/constant_path.rb +22 -7
  86. data/lib/rigor/reflection.rb +2 -3
  87. data/lib/rigor/scope/discovery_index.rb +218 -2
  88. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  89. data/lib/rigor/scope/resolution_chain.rb +884 -0
  90. data/lib/rigor/scope.rb +295 -322
  91. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  92. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  93. data/lib/rigor/sig_gen/generator.rb +22 -16
  94. data/lib/rigor/type/combinator.rb +25 -1
  95. data/lib/rigor/version.rb +1 -1
  96. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  97. data/sig/rigor/environment.rbs +2 -0
  98. data/sig/rigor/plugin/io_boundary.rbs +1 -0
  99. data/sig/rigor/scope.rbs +43 -1
  100. data/skills/rigor-project-init/SKILL.md +37 -11
  101. data/skills/rigor-project-init/references/02-configure.md +7 -2
  102. metadata +25 -1
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostic"
4
+ require_relative "path_expansion"
5
+ require_relative "result"
6
+ require_relative "run_cache_key"
7
+ require_relative "../cache/descriptor"
8
+ require_relative "../cache/file_digest"
9
+ require_relative "../cache/store"
10
+
11
+ module Rigor
12
+ module Analysis
13
+ # ADR-45 WD2 (#1507) — the run-result slot `rigor check --incremental` writes, and the engine-free probe that
14
+ # serves a null run from it. The writer is {IncrementalSession#run_incremental}; the reader is the CLI, before
15
+ # it loads the inference engine. Both go through this module, so the key, the producer id and the stored shape
16
+ # have one definition.
17
+ #
18
+ # It is ADR-45's record-and-validate slot with a second writer. The value is the diagnostics the session's run
19
+ # printed (before the baseline filter, which the CLI applies to every answer), and the dependency descriptor
20
+ # validates everything that answer was computed from:
21
+ #
22
+ # - one `:stat` row per analysed file, carrying the digest the SESSION holds for it — the bytes its cached rows
23
+ # were computed from — rather than a re-digest taken after the run;
24
+ # - the rows the run re-read itself ({Runner#incremental_slot_rows}' `observed` and `derived` rows);
25
+ # - the chain the {Entry} carries forward: the rows the last full run recorded for the inputs the incremental
26
+ # path does not re-derive on a recheck, and the plugin reads credited to each file's analysis
27
+ # ({IncrementalSession#write_run_slot}).
28
+ #
29
+ # A hit is at least as careful as the full incremental path — where that path would notice a change, a row
30
+ # notices it first — but it is not a cold run: a stale answer that path serves for a read reaching a file
31
+ # through a plugin's memo (#1553) the probe may serve too. ADR-45 WD2 states the guarantee.
32
+ #
33
+ # Kept apart from the plain run's `analysis.run-diagnostics` by its own producer id and by the roots entry its
34
+ # key adds, either enough alone, so neither probe can ever read the other's entry, and an incremental defect
35
+ # cannot leak into a default run.
36
+ #
37
+ # The key is the one {RunCacheProbe} reconstructs from configuration alone — the library list without a
38
+ # `rbs.virtual_rbs` slot, and no `template-units` slot — plus the analysis roots ({Target}). Neither left-out
39
+ # slot is needed. A virtual RBS buffer is a function of an analysed file's bytes (a row) and of the
40
+ # synthesising plugin's identity and configuration (the key's `configuration`, lockfile and engine slots). A
41
+ # template unit is a function of its template's bytes and of what its transform read (rows) and of the
42
+ # plugin's identity and the engine's (key slots), so a project whose plugins claim template globs is served
43
+ # too. The ADR-87 probe misses on one: the plain slot's key carries the `template-units` slot.
44
+ #
45
+ # `explain:` is always false: the session's runners never set it (#1533), and the CLI declines the probe under
46
+ # `--explain`.
47
+ module IncrementalRunSlot
48
+ module_function
49
+
50
+ PRODUCER_ID = "analysis.incremental-run-diagnostics"
51
+
52
+ # One live generation per analysed-path set, as for the plain slot. A writer that moves the path set discards
53
+ # the entry it carried from (see {IncrementalSession}), so the churn a file addition makes is bounded without
54
+ # a compaction pass — the incremental path never runs one.
55
+ GENERATION_CAP = RunCacheKey::GENERATION_CAP
56
+
57
+ # The stored value. `baseline` and `pinned` (each a {Cache::Descriptor}) and `reads` (`{path =>
58
+ # Cache::Descriptor}`) are the chain the next writer carries forward, kept apart from the rest of the
59
+ # dependency descriptor so it knows which rows are which; their entries are the same objects the descriptor
60
+ # holds, so `Marshal` writes them once. `baseline` and `pinned` hold the rows the last full run recorded for
61
+ # inputs the incremental path does not re-derive on a recheck (the signature tree, the
62
+ # discovered-not-analysed files, the `pre_eval:` files outside the analysed set), `pinned` the signature
63
+ # files the key identifies already ({Runner::BaselineRows}); `reads` the plugin reads credited to each
64
+ # analysed file. `snapshot` is the identity of
65
+ # the incremental snapshot file the writing run left behind, which the next writer compares against the one
66
+ # it restored before trusting the chain. `roots` are the analysis roots as the run was given them
67
+ # ({.as_written}): the key holds them as a set of paths, and the probe serves the entry only to a run that
68
+ # names them the same way, in the same order.
69
+ Entry = Data.define(:diagnostics, :roots, :baseline, :pinned, :reads, :snapshot)
70
+
71
+ # What a slot is keyed by: the analysed-path set and the analysis roots the run was given. The roots are
72
+ # not implied by the files — `rigor check --incremental lib extra` with `extra` missing analyses the same
73
+ # files as `rigor check --incremental lib`, and only the first reports `extra` as missing.
74
+ Target = Data.define(:files, :roots)
75
+
76
+ # What a probe hit hands the CLI: the answer, and the analysed-file count its banner reports.
77
+ Hit = Data.define(:result, :file_count)
78
+
79
+ def key(configuration:, target:)
80
+ base = RunCacheKey.descriptor(
81
+ configuration: configuration, files: target.files, explain: false,
82
+ rbs_config_entries: RunCacheKey.libraries_config_entries(configuration)
83
+ )
84
+ return nil if base.nil?
85
+
86
+ roots = RunCacheKey.config_entry("incremental.roots", normalize_roots(target.roots).sort.join("\n"))
87
+ Cache::Descriptor.new(gems: base.gems, configs: base.configs + [roots])
88
+ end
89
+
90
+ # The roots as absolute paths, which the KEY holds sorted: `lib`, `./lib` and `lib/` are one root there. A
91
+ # run that reorders its roots still finds the previous slot's chain (the snapshot fingerprint sorts them
92
+ # too), and one that respells them replaces the slot rather than adding one beside it.
93
+ def normalize_roots(roots)
94
+ Array(roots).map { |root| File.absolute_path(root.to_s) }
95
+ end
96
+
97
+ # The roots as the run was given them, which {Entry#roots} holds and {.serve} compares. A missing root is
98
+ # reported as written (`./extra: no such file or directory`), and `a b` lists `a`'s files before `b`'s, so
99
+ # neither a respelling nor a reordering may be served another's answer.
100
+ def as_written(roots)
101
+ Array(roots).map(&:to_s)
102
+ end
103
+
104
+ # The engine-free probe. Returns a {Hit} when the slot for this run's analysed-path set validates, or nil to
105
+ # decline: a miss, a stale row, an unreadable entry, a project with effect collection on (see below), or any
106
+ # failure at all — declining hands the run to the full path, which is always safe.
107
+ #
108
+ # Effect collection is declined here and never written by the session. Its configuration (`effects:`) is
109
+ # deliberately absent from `Configuration#to_h` and so from the key, and two of its rows are judged each run
110
+ # from state no engine-free path can rebuild (the plain probe's `envelope_lane_live?`, #428). With it off,
111
+ # the only effect row a run can print is `effect.annotations-unchecked`, which is a function of the
112
+ # signature tree and the analysed files' annotations — both validated rows — so it is served as stored.
113
+ def serve(configuration:, cache_root:, paths:)
114
+ return nil if configuration.effects_enabled?
115
+
116
+ files = PathExpansion.ruby_files(paths, configuration.exclude_patterns)
117
+ slot_key = key(configuration: configuration, target: Target.new(files: files, roots: paths))
118
+ return nil if slot_key.nil?
119
+
120
+ entry = validated_entry(configuration, cache_root, slot_key)
121
+ return nil unless entry.is_a?(Entry) && entry.diagnostics.is_a?(Array)
122
+ return nil unless entry.roots == as_written(paths)
123
+
124
+ Hit.new(result: Result.new(diagnostics: entry.diagnostics, stats: nil), file_count: files.size)
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ # The previous slot's {Entry}, read WITHOUT validating it: it is stale by construction, since the run that
130
+ # is asking changed something. nil when there is no such slot, or it is not an {Entry} — the writer then
131
+ # skips its own write rather than guess what the files it served from cache depended on.
132
+ def previous_entry(store:, configuration:, target:)
133
+ slot_key = key(configuration: configuration, target: target)
134
+ return nil if slot_key.nil?
135
+
136
+ entry = store.peek_unvalidated(producer_id: PRODUCER_ID, key_descriptor: slot_key)
137
+ return nil unless entry.is_a?(Entry) && entry.reads.is_a?(Hash)
138
+ return nil unless entry.baseline.is_a?(Cache::Descriptor) && entry.pinned.is_a?(Cache::Descriptor)
139
+
140
+ entry
141
+ end
142
+
143
+ # Writes `entry` as the slot for `target`, validated by `dependencies` (the whole descriptor, of which the
144
+ # entry's chain is a part). Returns whether it wrote.
145
+ def write(store:, configuration:, target:, entry:, dependencies:)
146
+ slot_key = key(configuration: configuration, target: target)
147
+ return false if slot_key.nil?
148
+
149
+ store.store_validated(
150
+ producer_id: PRODUCER_ID, key_descriptor: slot_key, generation_cap: GENERATION_CAP,
151
+ value: entry, dependencies: dependencies
152
+ )
153
+ end
154
+
155
+ def discard(store:, configuration:, target:)
156
+ slot_key = key(configuration: configuration, target: target)
157
+ slot_key && store.discard(producer_id: PRODUCER_ID, key_descriptor: slot_key)
158
+ end
159
+
160
+ # Validation runs in its own per-run digest scope, with the configuration's `cache.validation` mode, exactly
161
+ # as the plain probe's does.
162
+ def validated_entry(configuration, cache_root, slot_key)
163
+ store = Cache::Store.new(root: cache_root, max_bytes: configuration.cache_max_bytes)
164
+ Cache::FileDigest.with_run(strict: configuration.cache_validation_strict?) do
165
+ store.peek_validated(producer_id: PRODUCER_ID, key_descriptor: slot_key)
166
+ end
167
+ end
168
+ private_class_method :validated_entry
169
+ end
170
+ end
171
+ end
@@ -2,7 +2,10 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "incremental"
5
+ require_relative "incremental_run_slot"
6
+ require_relative "incremental_run_slot/write_guard"
5
7
  require_relative "plugin_fact_fingerprint"
8
+ require_relative "source_rbs_gate"
6
9
  require_relative "../cache/file_digest"
7
10
  require_relative "../cache/incremental_snapshot"
8
11
  require_relative "../effects/file_collection"
@@ -22,10 +25,11 @@ module Rigor
22
25
  # only `ΔF ∪ dependents[ΔF]`, and serves every other analyzed file from the cache — the body tier.
23
26
  #
24
27
  # The invariant the verify harness (and the spec) assert: `#recheck`'s merged diagnostics are
25
- # byte-identical (as a sorted set) to a full `--no-cache` re-analysis of the edited tree. This is the
26
- # `--verify-incremental` acceptance gate, here without disk persistence or CLI wiring (the cache is
27
- # in-process). It models the body tier only: an edit that adds / removes / moves a *file* is outside
28
- # the analyzed set it maintains and falls to a fresh {#baseline} (the structural tier is a later slice).
28
+ # byte-identical to a full `--no-cache` re-analysis of the edited tree, in the same order (#1524; the gate
29
+ # itself compares sorted sets). This is the `--verify-incremental` acceptance gate, here without disk
30
+ # persistence or CLI wiring (the cache is in-process). It models the body tier only: an edit that adds /
31
+ # removes / moves a *file* is outside the analyzed set it maintains and falls to a fresh {#baseline} (the
32
+ # structural tier is a later slice).
29
33
  # The class-length budget is relaxed: this is one cohesive orchestrator of the incremental state
30
34
  # (per-file diagnostics cache, the file-level / symbol-level / negative dependency graphs, and the ADR-85
31
35
  # seed bundles), clearer read together than split across micro-classes that would all share the same ivars.
@@ -159,6 +163,7 @@ module Rigor
159
163
  # Full baseline analysis with recording. Returns the run's diagnostics; populates the in-process cache
160
164
  # + dependency state.
161
165
  def baseline
166
+ @slot_carried_from = nil # ADR-45 WD2 — see {#load_snapshot}
162
167
  runner = build_runner(record_dependencies: true)
163
168
  diagnostics = run_runner(runner).diagnostics
164
169
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
@@ -167,7 +172,9 @@ module Rigor
167
172
  # must never read as a file that VANISHED from the project on the next recheck (which is what
168
173
  # `previous - current` would say, `current_files` being a `.rb` expansion).
169
174
  @analyzed = runner.analyzed_files - runner.template_unit_paths
170
- @seed_bundles = runner.seed_bundles # ADR-85 WD2 — the freshly built bundle set for the next run.
175
+ # ADR-85 WD2 — the freshly built bundle set for the next run, each stamped with its file's source-RBS
176
+ # digest (issue #1536).
177
+ @seed_bundles = source_rbs_gate.stamp(runner.seed_bundles, whole_project: true)
171
178
  absorb_dependency_graph(runner)
172
179
  @return_summaries = runner.return_summaries # ADR-89 WD2 — the full-run behavioural surface.
173
180
  # ADR-67 WD6c lift — the seed table the runner's own pre-pass computed ({} when the gate is off).
@@ -178,6 +185,7 @@ module Rigor
178
185
  @effects_identity = current_effects_identity
179
186
  @cache = per_file(runner.per_file_diagnostics)
180
187
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
188
+ forget_unbound_digests
181
189
  @run_level_rows = runner.run_level_rows
182
190
  diagnostics
183
191
  end
@@ -199,15 +207,16 @@ module Rigor
199
207
  param_files, param_pairs = param_seed_invalidation(fresh_params)
200
208
  affected = affected_closure(changed, added, removed, param_files, param_pairs)
201
209
  analyze_set = affected & current
210
+ reused = (current & previous) - affected.to_a
202
211
  # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
203
- # was decided on (and the collector runs once per recheck, not twice).
212
+ # was decided on (and the collector runs once per recheck, not twice). The reused files' cached rows
213
+ # go in too, so the runner lists them in the full run's file order (#1524).
204
214
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
205
215
  param_inferred_types: fresh_params,
206
- restored_run_level_rows: @run_level_rows)
207
- fresh = run_runner(runner).diagnostics
216
+ restored_run_level_rows: @run_level_rows,
217
+ served_per_file_diagnostics: @cache.slice(*reused))
218
+ merged = run_runner(runner).diagnostics
208
219
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
209
- reused = (current & previous) - affected.to_a
210
- merged = fresh + reused.flat_map { |path| @cache[path] || [] }
211
220
  absorb(runner, current, analyze_set, removed)
212
221
  @param_table = fresh_params
213
222
  Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
@@ -235,6 +244,7 @@ module Rigor
235
244
  # stale. A full baseline is the sound answer for `--incremental`, but in editor mode it is also the
236
245
  # latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
237
246
  break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
247
+ break nil if source_rbs_unverified?(result)
238
248
 
239
249
  result
240
250
  end
@@ -253,7 +263,20 @@ module Rigor
253
263
  # return exactly the way a body edit does, so it reuses the same audited dependents machinery. The
254
264
  # pairs join AFTER the ADR-89 WD2 behavioural-stability pruning: that gate re-evaluates returns under
255
265
  # the snapshot's OLD seeds, which is the wrong oracle for a pair whose seeds are the thing that moved.
266
+ #
267
+ # Issue #1536 (ADR-89 WD1 amendment) — every gate in {#gated_closure} reads the RUBY side of a file. What a
268
+ # source-RBS synthesizer derives from its comments reaches the environment as a `virtual:` buffer, and a
269
+ # reader of that RBS records no edge to the file it came from (an `attr_reader :name #: String` consumer
270
+ # records none at all), so no dependent set can bound who read it. An edit that moved it re-analyses the
271
+ # whole project — what a `sig/` edit already costs through the snapshot fingerprint.
256
272
  def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
273
+ return whole_project_closure(changed, added) if source_rbs_gate.moved?(@seed_bundles, changed, added, removed)
274
+
275
+ gated_closure(changed, added, removed, param_files, param_pairs)
276
+ end
277
+
278
+ # {#affected_closure} for an edit whose synthesized RBS is byte-identical in every file it touched.
279
+ def gated_closure(changed, added, removed, param_files, param_pairs)
257
280
  scan = changed + added
258
281
  # Parse the changed / added set ONCE for the per-symbol fingerprints, the class declarations, AND the
259
282
  # ADR-89 WD1 declaration signatures. They were separate `discovered_def_index_for_paths` passes over
@@ -396,13 +419,11 @@ module Rigor
396
419
  # dependents skippable) when its current {ScopeIndexer.declaration_signature} matches the one stored in
397
420
  # the snapshot's seed bundle — i.e. its edit changed no method signature, visibility, ancestry, member
398
421
  # layout, def line, or method existence, so every cross-file DECLARATION fact its dependents consume is
399
- # unchanged. Falls back to treating EVERY changed file as unstable (today's full closure) when a
400
- # comment-ingesting plugin is loaded — such a plugin reads the very comments the signature ignores, so a
401
- # comment edit it treats as a no-op could change a cross-file type. Sorbet sigs / dry-types includes are
402
- # CODE, captured by ADR-88's plugin-fact fingerprint (WD3), so only comment-as-input plugins escape here.
422
+ # unchanged. The signature reads code, not comments; the other half of stability — that no source-RBS
423
+ # synthesizer's output for the file moved — is settled before this runs ({SourceRbsGate#moved?}), so every
424
+ # file reaching here has a byte-identical synthesized contribution. Sorbet sigs / dry-types includes are
425
+ # CODE, captured by ADR-88's plugin-fact fingerprint (WD3).
403
426
  def declaration_unstable(changed, declaration_signatures)
404
- return changed if comment_ingesting_plugin_loaded?
405
-
406
427
  changed.reject { |path| declaration_stable?(path, declaration_signatures[path]) }
407
428
  end
408
429
 
@@ -415,14 +436,11 @@ module Rigor
415
436
  bundle[:declaration_signature] == current_declaration_signature
416
437
  end
417
438
 
418
- def comment_ingesting_plugin_loaded?
419
- # Mirrors the plugin loader's gem-name resolution (`ProjectPrePasses#trusted_gem_name`): a String
420
- # entry IS the gem name; a Hash entry names it under `"gem"` (or the manifest `"id"`).
421
- @configuration.plugins.any? do |entry|
422
- name = entry.is_a?(Hash) ? (entry["gem"] || entry["id"]) : entry
423
- COMMENT_INGESTING_PLUGIN_IDS.include?(name.to_s)
424
- end
439
+ # Every file this recheck can analyse: the removed ones drop out at `affected & current`.
440
+ def whole_project_closure(changed, added)
441
+ (@analyzed.to_set | changed | added).freeze
425
442
  end
443
+ private :gated_closure, :whole_project_closure
426
444
 
427
445
  # The current project file set (cheap directory expansion, no analysis), used to detect files added /
428
446
  # removed since the last run. Also where an editor buffer's binding is re-spelled onto the set, since
@@ -441,13 +459,13 @@ module Rigor
441
459
  # mutating session state. Returns the merged diagnostics.
442
460
  def reanalyze_subset(subset)
443
461
  affected = subset.to_set
462
+ reused = @analyzed - affected.to_a
444
463
  # ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
445
464
  # exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
446
465
  runner = build_runner(analyze_only: affected, param_inferred_types: @param_table,
447
- restored_run_level_rows: @run_level_rows)
448
- fresh = run_runner(runner).diagnostics
449
- reused = @analyzed - affected.to_a
450
- fresh + reused.flat_map { |path| @cache[path] || [] }
466
+ restored_run_level_rows: @run_level_rows,
467
+ served_per_file_diagnostics: @cache.slice(*reused))
468
+ run_runner(runner).diagnostics
451
469
  end
452
470
 
453
471
  # Cross-process incremental run (the `--incremental` flag's engine). With a disk `snapshot` whose
@@ -468,7 +486,7 @@ module Rigor
468
486
  # shares one digest memo across change detection and the baseline/absorb re-pack. The inner
469
487
  # `Runner#run` nests its own `with_run` for the analysis descriptors; nesting is safe (each restores).
470
488
  Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
471
- restored = fingerprint && snapshot.load(fingerprint: fingerprint)
489
+ restored = load_snapshot(snapshot, fingerprint)
472
490
  # ADR-88 WD1 — the plugin fact-surface fingerprint gates snapshot reuse the same way the global
473
491
  # fingerprint gates the load: a plugin sig/catalog edit outside `signature_paths:` (a Sorbet `.rbi`)
474
492
  # changes the types unchanged call sites resolve, without moving any analyzed file, so the global
@@ -489,7 +507,7 @@ module Rigor
489
507
  result = recheck
490
508
  adopt_plugin_fact_fingerprint
491
509
  fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
492
- if fact_reusable && effects_reusable
510
+ if fact_reusable && effects_reusable && !source_rbs_unverified?(result)
493
511
  diagnostics = result.diagnostics
494
512
  warm = true
495
513
  # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
@@ -497,9 +515,10 @@ module Rigor
497
515
  # A cold baseline always persists — there was no valid snapshot to reuse.
498
516
  skip_save = result.no_change?
499
517
  else
500
- # The fact surface moved (a plugin sig/catalog edit), a plugin is opaque, or the effects
501
- # identity moved: the cached-served files the recheck merged may be stale (or, for effects, a
502
- # partial collection cannot be closed), so re-analyze the whole tree. The current fact-surface
518
+ # The fact surface moved (a plugin sig/catalog edit), a plugin is opaque, the effects identity
519
+ # moved, or a synthesizer the source-RBS gate could not see was loaded (#1536): the cached-served
520
+ # files the recheck merged may be stale (or, for effects, a partial collection cannot be closed),
521
+ # so re-analyze the whole tree. The current fact-surface
503
522
  # digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
504
523
  # Only a genuine fact-surface reason sets the reporting flag the CLI banner reads.
505
524
  @fact_surface_invalidated = true unless fact_reusable
@@ -513,7 +532,7 @@ module Rigor
513
532
  warm = false
514
533
  skip_save = false
515
534
  end
516
- snapshot.save(fingerprint: fingerprint, payload: to_payload) if persist && fingerprint && !skip_save
535
+ persist_run(snapshot, fingerprint, diagnostics, skip_save: skip_save) if persist && fingerprint
517
536
  [diagnostics, warm]
518
537
  end
519
538
  end
@@ -538,11 +557,41 @@ module Rigor
538
557
 
539
558
  # ADR-88 WD1 — capture this invocation's fact-surface fingerprint (from the last analysis runner) onto the
540
559
  # reporting ivars + the `@plugin_fact_reusable` decision object.
560
+ #
561
+ # Issue #1536 — the same prepared registry settles whether the synthesizers the session digested with
562
+ # are the ones the run's environment was built from ({SourceRbsGate#verify}). A plugin that builds its
563
+ # synthesizer in `#prepare` leaves the gate untrusted, and every stored digest is stamped unknown before
564
+ # the snapshot is saved.
541
565
  def adopt_plugin_fact_fingerprint
542
- fact = compute_plugin_fact_fingerprint
566
+ registry = prepared_plugin_registry
567
+ fact = compute_plugin_fact_fingerprint(registry)
543
568
  @plugin_fact_digest = fact.digest
544
569
  @opaque_plugin_ids = fact.opaque_plugin_ids
545
570
  @plugin_fact_reusable = fact
571
+ @seed_bundles = source_rbs_gate.verify(registry, @seed_bundles)
572
+ end
573
+
574
+ # Issue #1536 — a recheck of an edit whose closure left some file out may have under-read a synthesizer
575
+ # the gate could not see. {#run_incremental} re-analyses the project and {#run_buffer_recheck} declines,
576
+ # the way each treats a moved plugin fact surface. A run that changed nothing is exempt: under an
577
+ # untrusted gate every earlier edit already re-analysed the whole project, so the snapshot it serves is
578
+ # whole, and a null run stays one.
579
+ def source_rbs_unverified?(result)
580
+ source_rbs_gate.untrusted? && !result.no_change? && !result.reused.empty?
581
+ end
582
+
583
+ # Issue #1536 — a file the gate could not stamp because it was saved after the closure was decided (its
584
+ # readers' cached answers predate that save) must read as changed on the next run, where its unknown
585
+ # stamp sends the edit to the whole project. Recording its post-save content digest would hide it.
586
+ def forget_unbound_digests
587
+ source_rbs_gate.unbound.each { |path| @digests.delete(path) }
588
+ end
589
+
590
+ # Issue #1536 — decides whether an edit moved synthesized RBS, and stamps each seed bundle's digest. One
591
+ # per session: its untrusted state must outlive every run the session makes.
592
+ def source_rbs_gate
593
+ @source_rbs_gate ||= SourceRbsGate.new(configuration: @configuration, cache_store: @cache_store,
594
+ plugin_requirer: @plugin_requirer)
546
595
  end
547
596
 
548
597
  # ADR-88 WD1 — the plugin fact-surface fingerprint for this invocation. For a SEQUENTIAL run it is read
@@ -551,17 +600,195 @@ module Rigor
551
600
  # published facts — there it falls back to the always-sequential probe. Both paths compute the identical
552
601
  # digest for a given fact surface, so the reuse decision is worker-count-independent (the parity spec
553
602
  # asserts this).
554
- def compute_plugin_fact_fingerprint
603
+ def compute_plugin_fact_fingerprint(registry = prepared_plugin_registry)
604
+ PluginFactFingerprint.from_registry(registry)
605
+ end
606
+
607
+ # The registry the run's environment was built from, after `#prepare`: the analysis runner's own on a
608
+ # sequential run, the always-sequential probe's on a pooled one (nil when the probe fails).
609
+ def prepared_plugin_registry
555
610
  registry = @last_runner&.plugin_registry
556
- if @workers.zero? && registry && !registry.empty?
557
- PluginFactFingerprint.from_registry(registry)
558
- else
559
- PluginFactFingerprint.compute(
560
- configuration: @configuration, cache_store: @cache_store, plugin_requirer: @plugin_requirer
561
- )
611
+ return registry if @workers.zero? && registry && !registry.empty?
612
+
613
+ PluginFactFingerprint.prepared_registry(
614
+ configuration: @configuration, cache_store: @cache_store, plugin_requirer: @plugin_requirer
615
+ )
616
+ end
617
+
618
+ # Loads the snapshot, noting (ADR-45 WD2) which write of the file it read: the identity is kept only when
619
+ # the file was the same before and after the read, since a rewrite in between leaves no telling which one
620
+ # the payload came from.
621
+ def load_snapshot(snapshot, fingerprint)
622
+ @slot_guard = start_slot_guard(fingerprint)
623
+ before = snapshot_identity(snapshot)
624
+ restored = fingerprint && snapshot.load(fingerprint: fingerprint)
625
+ @restored_snapshot_identity = restored && before == snapshot_identity(snapshot) ? before : nil
626
+ # The path set the previous run-result slot is keyed by, for a recheck to carry its chain from; a
627
+ # {#baseline} clears it, since a full run starts a chain of its own.
628
+ @slot_carried_from = restored&.analyzed
629
+ restored
630
+ end
631
+
632
+ # ADR-87 WD3 — a warm recheck that changed nothing skips the snapshot rewrite: the file it restored is still
633
+ # the one on disk, which is what the run-result slot records as its snapshot.
634
+ def persist_run(snapshot, fingerprint, diagnostics, skip_save:)
635
+ written = if skip_save
636
+ @restored_snapshot_identity
637
+ elsif snapshot.save(fingerprint: fingerprint, payload: to_payload)
638
+ snapshot_identity(snapshot)
639
+ end
640
+ write_run_slot(diagnostics, written_identity: written)
641
+ end
642
+
643
+ # ADR-45 WD2 (#1507) — records this run's answer as the run-result slot a later `rigor check --incremental`
644
+ # serves without loading the engine ({IncrementalRunSlot}). The slot's dependency descriptor must validate
645
+ # what the answer was computed from, and after a narrowed recheck most of the answer was computed by earlier
646
+ # runs, so it is assembled from three sources:
647
+ #
648
+ # - the analysed files, at the digest this session holds for each: the bytes its cached or fresh rows were
649
+ # computed from, never a re-digest taken after the fact;
650
+ # - what this run read and re-derives every run ({Runner#incremental_slot_rows}' `observed` and `derived`
651
+ # rows);
652
+ # - the CHAIN: the rows the last full run recorded for inputs a recheck does not re-derive
653
+ # ({Runner#baseline_dependency_rows}), and the plugin reads credited to each file's analysis. A full run
654
+ # starts a chain; a recheck takes the previous slot's, replaces the reads of every file it re-analysed with
655
+ # this run's, and drops the removed files'.
656
+ #
657
+ # A recheck writes only when the previous slot exists and was written against the very snapshot file it
658
+ # restored: otherwise some run in between — a pool run, `--no-cache`, a write that failed — analysed files
659
+ # without recording their reads, and the chain cannot vouch for them. It then writes nothing, and the next
660
+ # full run starts a fresh chain. Declining to write is always safe; it only leaves the next null run on the
661
+ # full path.
662
+ #
663
+ # Every row not recorded as a plugin read it must still describe the tree the run read
664
+ # ({IncrementalRunSlot::WriteGuard}): the analysed files' (whose session digest a full run takes after its
665
+ # analysis), the `derived` rows, and the chain's baseline, taken now or carried ({#guarded_rows}).
666
+ #
667
+ # @param written_identity — the snapshot file as this run leaves it, recorded for the next writer's check.
668
+ def write_run_slot(diagnostics, written_identity:)
669
+ return unless run_slot_writable?
670
+
671
+ roots = @paths || @configuration.paths
672
+ rows = @last_runner.incremental_slot_rows(roots: roots)
673
+ chain = rows && (@slot_carried_from ? carry_chain(rows, roots) : start_chain(rows))
674
+ analysed = chain && analysed_file_rows
675
+ return if analysed.nil?
676
+
677
+ baseline, pinned, reads = chain
678
+ return unless @slot_guard&.admits?(guarded_rows(analysed, rows.derived, baseline), pinned: pinned)
679
+
680
+ # A save the run read after the mark's tick must not hide behind an unmoved stat tuple.
681
+ reads = reads.transform_values { |descriptor| @slot_guard.mark_racy(descriptor) }
682
+
683
+ entry = IncrementalRunSlot::Entry.new(
684
+ diagnostics: diagnostics, roots: IncrementalRunSlot.as_written(roots),
685
+ baseline: baseline, pinned: pinned, reads: reads, snapshot: written_identity
686
+ )
687
+ dependencies = @slot_guard.mark_racy(run_slot_dependencies(analysed, rows, [baseline, pinned], reads.values))
688
+ wrote = IncrementalRunSlot.write(
689
+ store: @cache_store, configuration: @configuration, entry: entry, dependencies: dependencies,
690
+ target: IncrementalRunSlot::Target.new(files: @analyzed, roots: roots)
691
+ )
692
+ discard_previous_slot(roots) if wrote
693
+ rescue StandardError
694
+ nil
695
+ end
696
+
697
+ # The previous slot is keyed by the previous path set; once this run's stands, nothing asks for it again.
698
+ def discard_previous_slot(roots)
699
+ return if @slot_carried_from.nil? || @slot_carried_from.sort == @analyzed.sort
700
+
701
+ IncrementalRunSlot.discard(
702
+ store: @cache_store, configuration: @configuration,
703
+ target: IncrementalRunSlot::Target.new(files: @slot_carried_from, roots: roots)
704
+ )
705
+ end
706
+
707
+ # ADR-45 WD2 — the mark {IncrementalRunSlot::WriteGuard#admits?} checks the slot's rows against, taken before
708
+ # the run reads anything. nil (no slot this run) for a store no slot is written to, or no fingerprint.
709
+ def start_slot_guard(fingerprint)
710
+ return nil if fingerprint.nil? || @cache_store.nil? || @cache_store.read_only? || !@buffer.nil?
711
+ # Known before the run: none of these can be written afterwards, so the stamp and the recomputed
712
+ # fingerprint would be spent for nothing.
713
+ return nil unless @workers.zero? && !@configuration.effects_enabled?
714
+
715
+ IncrementalRunSlot::WriteGuard.start(
716
+ configuration: @configuration, roots: @paths || @configuration.paths, cache_root: @cache_store.root,
717
+ fingerprint: fingerprint
718
+ )
719
+ end
720
+
721
+ # The runs whose answer the slot may hold: a writable store, no editor buffer (whose bytes exist only in
722
+ # the editor), a sequential run (a pool worker's plugin reads never reach this process), effect collection
723
+ # off ({IncrementalRunSlot.serve} declines it), and no opaque plugin — the next full-path run would then be
724
+ # cold, not the warm run the probe stands in for.
725
+ def run_slot_writable?
726
+ !@cache_store.nil? && !@cache_store.read_only? && @buffer.nil? && @workers.zero? &&
727
+ !@configuration.effects_enabled? && @opaque_plugin_ids.empty? && !@last_runner.nil?
728
+ end
729
+
730
+ def start_chain(rows)
731
+ baseline = @last_runner.baseline_dependency_rows(files: @analyzed)
732
+ baseline && [baseline.owned, baseline.pinned, rows.by_file.slice(*@analyzed)]
733
+ end
734
+
735
+ def carry_chain(rows, roots)
736
+ return nil if @restored_snapshot_identity.nil?
737
+
738
+ previous = IncrementalRunSlot.previous_entry(
739
+ store: @cache_store, configuration: @configuration,
740
+ target: IncrementalRunSlot::Target.new(files: @slot_carried_from, roots: roots)
741
+ )
742
+ return nil if previous.nil? || previous.snapshot != @restored_snapshot_identity
743
+
744
+ served = previous.reads.except(*@last_runner.analyzed_files)
745
+ [previous.baseline, previous.pinned, served.merge(rows.by_file).slice(*@analyzed)]
746
+ end
747
+
748
+ # One `:stat` row per analysed file, from the entry this session recorded for it (`#pack_digest`), re-packed
749
+ # against the file's current stat when the bytes still hash to the recorded digest but the tuple moved (a
750
+ # `touch`, a checkout): the snapshot keeps the old tuple, and a row carrying it would cost every later probe
751
+ # a re-hash of the file. nil when any file has no usable entry (it could not be read): no row could say what
752
+ # its answer was computed from.
753
+ def analysed_file_rows
754
+ @analyzed.map do |path|
755
+ packed = Cache::FileDigest.refresh_stat(path, @digests[path])
756
+ return nil if packed.nil?
757
+
758
+ Cache::Descriptor::FileEntry.new(path: path, comparator: :stat, value: packed)
562
759
  end
563
760
  end
564
761
 
762
+ # @param chained — the chain's baseline descriptors, owned and pinned
763
+ def run_slot_dependencies(analysed, rows, chained, reads)
764
+ carried = chained + reads
765
+ Cache::Descriptor.new(
766
+ files: analysed + rows.derived.files + rows.observed.files + carried.flat_map(&:files),
767
+ globs: (rows.derived.globs + rows.observed.globs + carried.flat_map(&:globs)).uniq
768
+ )
769
+ end
770
+
771
+ # What the write guard asks about besides the pinned baseline: every row the key does not pin that was not
772
+ # recorded as a plugin read it. A recheck's carried baseline is among them. The run that took it vouched for
773
+ # the tree it saw, but this run reads those inputs again (a recheck with a non-empty closure rebuilds its
774
+ # environment from the signature tree), and a save it read that is reverted before the run ends leaves every
775
+ # carried row fresh while the answer came from the saved bytes.
776
+ def guarded_rows(analysed, derived, baseline)
777
+ Cache::Descriptor.new(files: analysed + derived.files + baseline.files, globs: derived.globs + baseline.globs)
778
+ end
779
+
780
+ # The snapshot file's `(size, mtime_ns, ctime_ns, inode)`: which write of it a run restored, or left behind.
781
+ # A rewrite renames a new file into place, so any write moves it. nil when there is no file to stat.
782
+ def snapshot_identity(snapshot)
783
+ path = snapshot.respond_to?(:path) ? snapshot.path : nil
784
+ return nil if path.nil?
785
+
786
+ stat = File.stat(path)
787
+ [stat.size, Cache::FileDigest.ns_of(stat.mtime), Cache::FileDigest.ns_of(stat.ctime), stat.ino]
788
+ rescue SystemCallError
789
+ nil
790
+ end
791
+
565
792
  # Adopt a persisted snapshot's per-file state as this session's baseline (the warm-start path).
566
793
  def restore(payload)
567
794
  @analyzed = payload.analyzed
@@ -723,13 +950,16 @@ module Rigor
723
950
  removed.each { |path| forget(path) }
724
951
  @analyzed = current
725
952
  # ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
726
- # re-walked, removed files dropped, added files built), so adopt the runner's current set wholesale.
727
- @seed_bundles = runner.seed_bundles
953
+ # re-walked, removed files dropped, added files built), so adopt the runner's current set wholesale,
954
+ # stamping the re-walked ones with their source-RBS digest (issue #1536).
955
+ whole_project = current.all? { |path| analyze_set.include?(path) }
956
+ @seed_bundles = source_rbs_gate.stamp(runner.seed_bundles, whole_project: whole_project)
728
957
  fresh_by_file = per_file(runner.per_file_diagnostics)
729
958
  analyze_set.each do |path|
730
959
  @cache[path] = fresh_by_file[path] || []
731
960
  @digests[path] = pack_digest(path)
732
961
  end
962
+ forget_unbound_digests
733
963
  absorb_dependency_graph(runner)
734
964
  # Issues #796 / #794 — the recheck's own snapshots already carry the replayed rows folded together
735
965
  # with anything its closure demanded for itself, so this is the same union the next run replays.
@@ -1017,12 +1247,6 @@ module Rigor
1017
1247
  TOP_LEVEL_KEY = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
1018
1248
  private_constant :TOP_LEVEL_KEY
1019
1249
 
1020
- # B1 — plugin require-names that ingest COMMENT content as semantic input (inline-RBS reads `# @rbs` /
1021
- # `#:` annotations). B1's code fingerprint ignores comments, so a project configuring one of these opts
1022
- # OUT of the bundle-equality skip (a comment edit could change a cross-file type it contributes).
1023
- COMMENT_INGESTING_PLUGIN_IDS = %w[rigor-rbs-inline].freeze
1024
- private_constant :COMMENT_INGESTING_PLUGIN_IDS
1025
-
1026
1250
  def negative_key_for(symbol)
1027
1251
  class_name, method = symbol.split("#", 2)
1028
1252
  class_name == TOP_LEVEL_KEY ? "toplevel:#{method}" : "method:#{symbol}"