Class: Rigor::Analysis::IncrementalSession

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/analysis/incremental_session.rb

Overview

ADR-46 slice 2 — the in-memory incremental orchestrator that composes the recorded dependency graph (Runner#file_dependents), the affected closure (Rigor::Analysis::Incremental.affected), and the subset-analysis hook (Runner analyze_only:) into a working incremental re-check.

#baseline runs a full analysis with dependency recording and keeps, per analyzed file, its diagnostics (the cache), its content digest, and the per-file source set (to maintain the dependents index across rounds). #recheck digests the files again, computes the changed set ΔF, re-analyzes only ΔF ∪ dependents[ΔF], and serves every other analyzed file from the cache — the body tier.

The invariant the verify harness (and the spec) assert: #recheck's merged diagnostics are byte-identical (as a sorted set) to a full --no-cache re-analysis of the edited tree. This is the --verify-incremental acceptance gate, here without disk persistence or CLI wiring (the cache is in-process). It models the body tier only: an edit that adds / removes / moves a file is outside the analyzed set it maintains and falls to a fresh #baseline (the structural tier is a later slice). The class-length budget is relaxed: this is one cohesive orchestrator of the incremental state (per-file diagnostics cache, the file-level / symbol-level / negative dependency graphs, and the ADR-85 seed bundles), clearer read together than split across micro-classes that would all share the same ivars.

Defined Under Namespace

Classes: Recheck

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil, workers: 0, buffer: nil) ⇒ IncrementalSession

Returns a new instance of IncrementalSession.

Parameters:

  • paths (Array<String>, nil) (defaults to: nil)

    explicit analysis roots; nil (the default) uses the configuration's paths:.

  • environment (Rigor::Environment, nil) (defaults to: nil)

    optional shared environment to thread into each internal Runner. Long-lived callers and specs can use this to avoid rebuilding the same RBS universe for every baseline / recheck / oracle run.

  • cache_store (Rigor::Cache::Store, nil) (defaults to: nil)

    ADR-85 WD1 — the persistent cache each internal Runner exposes to the RBS-env and plugin-producer tiers. A cross-process --incremental recheck otherwise rebuilt a fresh runner with no store, so every plugin #prepare producer (the ADR-9/#74/ADR-60 WD3 record-and-validate caches) recomputed per invocation — 86% of a Rails warm incremental. Threading the store lets those producers serve from disk. nil (the default) preserves the pre-#85 behaviour the specs assert; the whole-run ADR-45 result cache stays disabled on these runs (Runner#run_result_cacheable? excludes record_dependencies / analyze_only).

  • plugin_requirer (#call, nil) (defaults to: nil)

    optional gem-require hook threaded into each internal Runner (mirrors Runner's parameter). nil (the default, and what the CLI passes) uses Kernel.require; embedders and specs inject a fake so a test plugin registers without touching the real load path.

  • workers (Integer) (defaults to: 0)

    ADR-46 — the resolved fork-pool worker count threaded into every internal analyzer, so a --incremental recheck's closure re-analysis parallelises exactly like the standard check path (the recon's audit: --workers / RIGOR_RACTOR_WORKERS / parallel.workers: were silently ignored because build_runner passed no workers:). 0 (the default, and what the specs and --verify-incremental gate use) keeps the sequential recording path bit-for-bit unchanged. The fork pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the dependency graph a pooled recheck rebuilds equals the sequential one.



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/rigor/analysis/incremental_session.rb', line 64

def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil,
               workers: 0, buffer: nil)
  @configuration = configuration
  @paths = paths
  # Editor mode option B (#146) — the in-flight buffer, threaded into every internal Runner so the
  # pre-passes and the closure re-analysis read the editor's bytes at the logical path. A session
  # holding one MUST NOT persist its snapshot: its `@digests` and `@cache` describe bytes that exist
  # only in the editor. {#run_buffer_recheck} is the only entry that honours that.
  @buffer = buffer
  @environment = environment
  @cache_store = cache_store
  @plugin_requirer = plugin_requirer
  @workers = workers
  # ADR-85 WD2 — per-file discovery seed bundles keyed by logical path. A cold baseline builds them; a
  # warm recheck folds them (re-walking only changed files) and refreshes the set. Ride the snapshot.
  @seed_bundles = {}
  @cache = {}              # analyzed path => [Diagnostic]
  @sources = {}            # analyzed path => Set<source path it read from>
  @digests = {}            # analyzed path => ADR-87 packed stat-digest entry at last analysis
  @analyzed = []           # the project files analyzed last round
  @dependents = {}         # inverted @sources (file-level)
  # ADR-46 slice 4 — symbol-granularity tracking.
  @symbol_sources = {}     # consumer => { source_path => Set<"ClassName#method"> }
  @ancestry_sources = {}   # consumer => Set<source_path> (class-ancestry deps)
  @symbol_fingerprints = {}  # path => { "ClassName#method" => sha256_hex }
  @symbol_dependents = {}    # [source, symbol] => Set<consumer>
  @ancestry_dependents = {}  # source => Set<consumer> (inverted ancestry_sources)
  # ADR-46 slice 3 — negative (missing) dependencies: a consumer that
  # looked up a name and resolved nothing must be re-checked when that
  # name later appears (e.g. a `call.unresolved-toplevel` whose target
  # is defined by a later edit).
  @missing = {}              # consumer => Set<"kind:name"> it looked up and missed
  @negative_dependents = {}  # "kind:name" => Set<consumer> (inverted @missing)
  @class_decls = {}          # path => Set<qualified class name declared in the file>
  # ADR-89 WD2 — per-def observed-key return summaries: [path, symbol] => { keys:, returns:, effects: }.
  # Harvested from the ADR-84 return memo after each run; drives the behavioural-stability gate.
  @return_summaries = {}
  # ADR-67 WD6c lift — the `parameter_inference:` seed table the cached diagnostics were computed
  # under (`{}` when the gate is off — the gate state is constant across a snapshot's lifetime because
  # the configuration is part of the global fingerprint). A recheck recomputes the table fresh and
  # diffs it against this: the pre-pass is whole-project by design, so the fresh table is ground truth
  # and a missing invalidation edge is impossible by construction — the reason this is a table diff
  # and not the caller→callee edge recording #204 first sketched.
  @param_table = {}
  # ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
  # `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
  # banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
  # post-hoc fingerprint reads from; `@plugin_fact_reusable` is the computed decision object.
  @plugin_fact_digest = nil
  @opaque_plugin_ids = [].freeze
  @fact_surface_invalidated = false
  @last_runner = nil
  @plugin_fact_reusable = nil
end

Instance Attribute Details

#opaque_plugin_idsObject (readonly)

ADR-88 WD1 — reporting hooks the CLI reads after #run_incremental. fact_surface_invalidated? is true when a valid snapshot was dropped for a fact-surface reason (a plugin sig/catalog edit, or an opaque plugin) rather than a cold miss. opaque_plugin_ids names the contributing-but-surfaceless plugins that force a full run every invocation.



454
455
456
# File 'lib/rigor/analysis/incremental_session.rb', line 454

def opaque_plugin_ids
  @opaque_plugin_ids
end

Instance Method Details

#affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new) ⇒ Object

The frozen set of files a #recheck must re-analyse: the symbol/ancestry-granularity closure of the changed files (slice 4), the added files themselves, the consumers of any symbol / class that appeared in a changed OR added file (slice 3 — a now-defined call.unresolved-toplevel target or def.override-* ancestor), and the consumers of every removed file (which now miss what it provided). An added file has no before-state, so all its symbols / classes appear.

ADR-67 WD6c lift — param_files / param_pairs are the callee files (and their [file, symbol] pairs) whose inferred-param seeds moved since the snapshot. Their text is unchanged, so they enter the closure here: the files themselves re-analyse (their in-body diagnostics were computed under the old seeds), and their pairs join the SYMBOL fan-out — a seed change shifts the callee's inferred return exactly the way a body edit does, so it reuses the same audited dependents machinery. The pairs join AFTER the ADR-89 WD2 behavioural-stability pruning: that gate re-evaluates returns under the snapshot's OLD seeds, which is the wrong oracle for a pair whose seeds are the thing that moved.



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/rigor/analysis/incremental_session.rb', line 213

def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
  scan = changed + added
  # Parse the changed / added set ONCE for the per-symbol fingerprints, the class declarations, AND the
  # ADR-89 WD1 declaration signatures. They were separate `discovered_def_index_for_paths` passes over
  # the same `scan` set — a duplicate re-parse of every changed file each recheck (recon §2 / the P6
  # recheck-floor audit).
  # `buffer:` is load-bearing for editor mode option B (#146), not an optimisation: this scan decides
  # the closure, so reading the buffer's logical path from DISK would compare the snapshot's symbol
  # fingerprints against bytes the user has already edited away — every dependent of the unsaved change
  # would then be served from cache, which is the stale answer whole-project editor scope exists to
  # avoid. `ScopeIndexer.scan_summary_for_paths` resolves each path through the binding.
  summary = scan.empty? ? nil : Inference::ScopeIndexer.scan_summary_for_paths(scan, buffer: @buffer)
  scan_index = summary && summary[:def_index]
  declaration_signatures = (summary && summary[:declaration_signatures]) || {}
  new_fps = symbol_fingerprints_from_index(scan_index)
  new_class_decls = class_declarations_from_index(scan_index)
  changed_pairs = Incremental.changed_symbol_pairs(changed, @symbol_fingerprints, new_fps)
  # ADR-89 WD1 — a changed file whose DECLARATION signature (per-def parameter shape / visibility /
  # ancestry / member layout / def line, bodies excluded) is byte-identical to the snapshot is
  # declaration-stable: every cross-file fact its ancestry / file-level dependents consume is
  # declaration-derived and therefore unchanged (a body edit is consumed only by SYMBOL dependents,
  # governed below by `changed_pairs`), so those dependents are skipped. Only the declaration-UNSTABLE
  # changed files contribute their ancestry / file-level dependents; the changed files themselves are
  # always re-analysed (`changed.to_set`) to learn their own diagnostics. This generalises the B1
  # comment-only gate (code-stable ⟹ declaration-stable) to same-line body edits.
  unstable = declaration_unstable(changed, declaration_signatures)
  # ADR-89 WD2 — a declaration-stable changed def whose behavioural surface (return type at every
  # previously-observed call key + content-mutation effects) is unchanged is behaviourally stable: its
  # symbol dependents' cached diagnostics stay valid, so drop them. `symbol_pairs` is `changed_pairs`
  # minus those stable pairs, and only it (not `changed_pairs`) drives the symbol-dependent fan-out.
  symbol_pairs = behaviourally_unstable_pairs(changed_pairs, unstable, scan_index) | param_pairs
  base = dependents_base(unstable, symbol_pairs)
  closure = base | changed.to_set | added.to_set | negative_affected(scan, new_fps, new_class_decls)
  closure = param_seed_closure(closure, param_files)
  removed.each { |path| closure |= @dependents[path] || Set.new }
  closure.freeze
end

#analyzed_filesObject

The project files analyzed at the last baseline / recheck — the set a verify pass partitions and the merge subtracts the affected closure from.



121
122
123
# File 'lib/rigor/analysis/incremental_session.rb', line 121

def analyzed_files
  @analyzed
end

#baselineObject

Full baseline analysis with recording. Returns the run's diagnostics; populates the in-process cache

  • dependency state.


127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/rigor/analysis/incremental_session.rb', line 127

def baseline
  runner = build_runner(record_dependencies: true)
  diagnostics = run_runner(runner).diagnostics
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
  @analyzed = runner.analyzed_files
  @seed_bundles = runner.seed_bundles # ADR-85 WD2 — the freshly built bundle set for the next run.
  absorb_dependency_graph(runner)
  @return_summaries = runner.return_summaries # ADR-89 WD2 — the full-run behavioural surface.
  # ADR-67 WD6c lift — the seed table the runner's own pre-pass computed ({} when the gate is off).
  # Reading it back, rather than computing it here, keeps the baseline single-collect.
  @param_table = runner.param_inferred_types
  @cache = per_file(diagnostics)
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
  diagnostics
end

#behaviourally_unstable_pairs(changed_pairs, unstable, scan_index) ⇒ Object

ADR-89 WD2 — changed_pairs minus the behaviourally-STABLE pairs whose symbol dependents may be skipped. A pair [path, "Class#method"] is a candidate when its file is declaration-stable (WD1), it carries a persisted return summary, and the (edited) def is GATE-ELIGIBLE — its only cross-file body surfaces are its return type and its content-mutation effects (no instance/class variable write, no yield, no implicit-self call whose transitive effect a caller could observe). For a candidate the session compares its content-mutation effects (pure AST) then re-evaluates its return at every persisted key (a runner probe); a pair passes only when BOTH are unchanged. Any missing summary, ineligible def, changed effect, changed return, or uncomputable key keeps the pair — the conservative direction the --verify-incremental and byte-identical recheck specs backstop.



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/rigor/analysis/incremental_session.rb', line 321

def behaviourally_unstable_pairs(changed_pairs, unstable, scan_index)
  return changed_pairs if @return_summaries.empty? || scan_index.nil?

  candidates = changed_pairs.select do |pair|
    !unstable.include?(pair.first) && @return_summaries.key?(pair) &&
      gate_eligible_def?(scan_def_node(pair.last, scan_index))
  end
  return changed_pairs if candidates.empty?

  effect_stable = candidates.select { |pair| effects_unchanged?(pair, scan_index) }
  return changed_pairs if effect_stable.empty?

  stable = return_stable_pairs(effect_stable, scan_index)
  stable.empty? ? changed_pairs : (changed_pairs - stable)
end

#comment_ingesting_plugin_loaded?Boolean

Returns:

  • (Boolean)


361
362
363
364
365
366
367
368
# File 'lib/rigor/analysis/incremental_session.rb', line 361

def comment_ingesting_plugin_loaded?
  # Mirrors the plugin loader's gem-name resolution (`ProjectPrePasses#trusted_gem_name`): a String
  # entry IS the gem name; a Hash entry names it under `"gem"` (or the manifest `"id"`).
  @configuration.plugins.any? do |entry|
    name = entry.is_a?(Hash) ? (entry["gem"] || entry["id"]) : entry
    COMMENT_INGESTING_PLUGIN_IDS.include?(name.to_s)
  end
end

#current_filesObject

The current project file set (cheap directory expansion, no analysis), used to detect files added / removed since the last run.



372
373
374
375
# File 'lib/rigor/analysis/incremental_session.rb', line 372

def current_files
  runner = build_runner
  @paths ? runner.analysis_file_set(@paths) : runner.analysis_file_set
end

#declaration_stable?(path, current_declaration_signature) ⇒ Boolean

Returns:

  • (Boolean)


352
353
354
355
356
357
358
359
# File 'lib/rigor/analysis/incremental_session.rb', line 352

def declaration_stable?(path, current_declaration_signature)
  return false if current_declaration_signature.nil?

  bundle = @seed_bundles[path]
  return false if bundle.nil?

  bundle[:declaration_signature] == current_declaration_signature
end

#declaration_unstable(changed, declaration_signatures) ⇒ Object

ADR-89 WD1 — the changed files whose ancestry / file-level dependents must STILL be re-checked: those not provably declaration-stable. A changed file is declaration-STABLE (its ancestry / file-level dependents skippable) when its current ScopeIndexer.declaration_signature matches the one stored in the snapshot's seed bundle — i.e. its edit changed no method signature, visibility, ancestry, member layout, def line, or method existence, so every cross-file DECLARATION fact its dependents consume is unchanged. Falls back to treating EVERY changed file as unstable (today's full closure) when a comment-ingesting plugin is loaded — such a plugin reads the very comments the signature ignores, so a comment edit it treats as a no-op could change a cross-file type. Sorbet sigs / dry-types includes are CODE, captured by ADR-88's plugin-fact fingerprint (WD3), so only comment-as-input plugins escape here.



346
347
348
349
350
# File 'lib/rigor/analysis/incremental_session.rb', line 346

def declaration_unstable(changed, declaration_signatures)
  return changed if comment_ingesting_plugin_loaded?

  changed.reject { |path| declaration_stable?(path, declaration_signatures[path]) }
end

#dependents_base(unstable, symbol_pairs) ⇒ Object

The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable symbol pairs: the ADR-46 slice-4 symbol-granular fan-out when either is present (ancestry deps of the unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out.



263
264
265
266
267
268
269
# File 'lib/rigor/analysis/incremental_session.rb', line 263

def dependents_base(unstable, symbol_pairs)
  if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
    Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
  else
    Incremental.affected(unstable, @dependents)
  end
end

#fact_surface_invalidated?Boolean

Returns:

  • (Boolean)


456
457
458
# File 'lib/rigor/analysis/incremental_session.rb', line 456

def fact_surface_invalidated?
  @fact_surface_invalidated
end

#fresh_param_table(current, changed, added, removed) ⇒ Object

ADR-67 WD6c lift — the fresh whole-project inferred-param table for this recheck ({} when the gate is off). When NO file moved, the collector's inputs are unchanged — the project files are identical, and the env-side inputs (configuration, sig/, the gem set, the engine version) are constant under a matched snapshot fingerprint — so the stored table is provably identical and the whole-project re-collect is skipped (the ADR-87 null-recheck fast path stays collect-free).



276
277
278
279
280
281
# File 'lib/rigor/analysis/incremental_session.rb', line 276

def fresh_param_table(current, changed, added, removed)
  return {} unless @configuration.parameter_inference
  return @param_table if changed.empty? && added.empty? && removed.empty?

  build_runner.collect_param_inference_table(current)
end

#param_seed_closure(closure, param_files) ⇒ Object

ADR-67 WD6c lift — the seed-invalidated callees' own contribution to the closure: the files themselves, plus — on a pre-slice-4 snapshot with no symbol edges, where the pairs' symbol fan-out found nothing — their file-level dependents (wider, always sound).



254
255
256
257
258
# File 'lib/rigor/analysis/incremental_session.rb', line 254

def param_seed_closure(closure, param_files)
  closure |= param_files
  param_files.each { |path| closure |= @dependents[path] || Set.new } if @symbol_sources.empty?
  closure
end

#param_seed_invalidation(fresh_params) ⇒ Object

ADR-67 WD6c lift — the [files, pairs] the fresh table invalidates. For every [class, method, kind] entry that differs from the snapshot's copy (added, removed, or value-changed — Type#== structural equality, the same comparison the collector's own fixpoint termination uses, already exercised across Marshal round-trips by its fork workers), every snapshot file defining that symbol re-analyses and its [file, symbol] pair joins the symbol fan-out. An entry attributable to NO snapshot file is a def that first appeared in this edit: its file is in the changed/added set (so it re-analyses anyway) and its prior callers are the negative-dependency closure's job — nothing is lost by skipping it here. The restored types are compared and then DISCARDED (the run seeds from the fresh table), so a cache-carried stale memo ivar can never poison a live lookup.



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/rigor/analysis/incremental_session.rb', line 292

def param_seed_invalidation(fresh_params)
  return [Set.new, Set.new] if fresh_params.equal?(@param_table) || @param_table == fresh_params

  files = Set.new
  pairs = Set.new
  (@param_table.keys | fresh_params.keys).each do |key|
    next if @param_table[key] == fresh_params[key]

    class_name, method_name, kind = key
    symbol = "#{class_name}#{kind == :singleton ? '.' : '#'}#{method_name}"
    @symbol_fingerprints.each do |path, symbols|
      next unless symbols.key?(symbol)

      files << path
      pairs << [path, symbol]
    end
  end
  [files, pairs]
end

#reanalyze_subset(subset) ⇒ Object

Verification engine (the --verify-incremental gate): with NO source edit, re-analyze subset fresh and serve every other analyzed file from the baseline cache. Because nothing on disk changed, the merged result MUST equal a full analysis — so this exercises the subset-analysis and cache-merge paths against a known-good oracle (a full --no-cache run) for an arbitrary partition, without mutating session state. Returns the merged diagnostics.



382
383
384
385
386
387
388
389
390
# File 'lib/rigor/analysis/incremental_session.rb', line 382

def reanalyze_subset(subset)
  affected = subset.to_set
  # ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
  # exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
  runner = build_runner(analyze_only: affected, param_inferred_types: @param_table)
  fresh = run_runner(runner).diagnostics
  reused = @analyzed - affected.to_a
  fresh + reused.flat_map { |path| @cache[path] || [] }
end

#recheckObject

Re-check after on-disk edits, including files added or removed since the last run (the structural tier). Re-analyzes only the affected closure and serves the rest from cache; refreshes the cache + dependency state so a subsequent #recheck sees the new world.



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/rigor/analysis/incremental_session.rb', line 146

def recheck
  previous = @analyzed
  current = current_files
  added = current - previous
  removed = previous - current
  changed = changed_paths(current & previous)
  # ADR-67 WD6c lift — recompute the whole-project inferred-param table BEFORE deciding the closure,
  # and diff it against the snapshot's copy: an entry that moved (because a caller's argument type
  # changed, a caller appeared, or one vanished) invalidates the CALLEE's file and its symbol
  # dependents, none of which the file-digest tier can see (the callee's text is unchanged).
  fresh_params = fresh_param_table(current, changed, added, removed)
  param_files, param_pairs = param_seed_invalidation(fresh_params)
  affected = affected_closure(changed, added, removed, param_files, param_pairs)
  analyze_set = affected & current
  # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
  # was decided on (and the collector runs once per recheck, not twice).
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
                        param_inferred_types: fresh_params)
  fresh = run_runner(runner).diagnostics
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
  reused = (current & previous) - affected.to_a
  merged = fresh + reused.flat_map { |path| @cache[path] || [] }
  absorb(runner, fresh, current, analyze_set, removed)
  @param_table = fresh_params
  Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
              removed: removed.to_set, affected: affected, reused: reused.to_set)
end

#run_buffer_recheck(snapshot:, fingerprint:) ⇒ Object

Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one file, for the CLI's --incremental --tmp-file=X --instead-of=Y. Returns the Recheck when the snapshot could be reused, or nil when it could not — the caller then falls back to option A (single-file scope) rather than paying a full baseline, because that baseline would repeat on every keystroke: this session MUST NOT save, so nothing it computes can warm the next invocation.

Not saving is the whole safety story. @digests and @cache here describe the buffer's bytes, which exist only in the editor; persisting them would make the next rigor check --incremental believe the on-disk file was already analysed in a state it was never in.



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/rigor/analysis/incremental_session.rb', line 183

def run_buffer_recheck(snapshot:, fingerprint:)
  Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
    restored = fingerprint && snapshot.load(fingerprint: fingerprint)
    break nil unless restored

    restore(restored)
    result = recheck
    adopt_plugin_fact_fingerprint
    # The ADR-88 gate applies unchanged: if the plugin fact surface moved, the cache-served files may be
    # stale. A full baseline is the sound answer for `--incremental`, but in editor mode it is also the
    # latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
    break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)

    result
  end
end

#run_incremental(snapshot:, fingerprint:, persist: true) ⇒ Object

Cross-process incremental run (the --incremental flag's engine). With a disk snapshot whose fingerprint matches, restore the prior per-file state and #recheck (re-analyze only the changed closure, serve the rest from the restored cache); otherwise run a full #baseline. Either way, persist the updated snapshot for the next process. Returns [diagnostics, warm]warm is true when a snapshot was restored. A nil fingerprint (uncomputable inputs) disables persistence: a plain full run. persist: false runs the same restore / recheck / baseline decision without writing the snapshot back. The language server (#246) needs exactly that: it keeps its session in memory for the life of the process, and writing shared state would race the way its read-only cache store already declines to. Every soundness gate below — the fingerprint, the ADR-88 fact surface — is unchanged, so the decision to reuse is made identically whether or not the result is saved.



403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
# File 'lib/rigor/analysis/incremental_session.rb', line 403

def run_incremental(snapshot:, fingerprint:, persist: true)
  # ADR-87 WD1 — install the per-run digest table + recording instant + strict flag for the whole
  # invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
  # `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
  # shares one digest memo across change detection and the baseline/absorb re-pack. The inner
  # `Runner#run` nests its own `with_run` for the analysis descriptors; nesting is safe (each restores).
  Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
    restored = fingerprint && snapshot.load(fingerprint: fingerprint)
    # ADR-88 WD1 — the plugin fact-surface fingerprint gates snapshot reuse the same way the global
    # fingerprint gates the load: a plugin sig/catalog edit outside `signature_paths:` (a Sorbet `.rbi`)
    # changes the types unchanged call sites resolve, without moving any analyzed file, so the global
    # fingerprint stays fresh and the recheck would serve stale diagnostics. The fingerprint is computed
    # POST-HOC from the analysis runner (which already ran `#prepare` and validated its producers) so the
    # warm path pays no second `#prepare`; the decision is applied after the recheck. Opaque plugins
    # (types with no fingerprint surface) make the snapshot un-reusable.
    if restored
      restore(restored)
      result = recheck
      adopt_plugin_fact_fingerprint
      reuse = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
      if reuse
        diagnostics = result.diagnostics
        warm = true
        # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
        # snapshot it restored, so skip the unconditional rewrite (209ms + 2 MB on gitlab per null recheck).
        # A cold baseline always persists — there was no valid snapshot to reuse.
        skip_save = result.no_change?
      else
        # The fact surface moved (a plugin sig/catalog edit) or a plugin is opaque: the cached-served
        # files the recheck merged may be stale, so re-analyze the whole tree. The current fact-surface
        # digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
        @fact_surface_invalidated = true
        diagnostics = baseline
        warm = false
        skip_save = false
      end
    else
      diagnostics = baseline
      adopt_plugin_fact_fingerprint
      warm = false
      skip_save = false
    end
    snapshot.save(fingerprint: fingerprint, payload: to_payload) if persist && fingerprint && !skip_save
    [diagnostics, warm]
  end
end