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`, 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).

Defined Under Namespace

Classes: Recheck

Instance Method Summary collapse

Constructor Details

#initialize(configuration:, paths: 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:`.



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/rigor/analysis/incremental_session.rb', line 38

def initialize(configuration:, paths: nil)
  @configuration = configuration
  @paths = paths
  @cache = {}              # analyzed path => [Diagnostic]
  @sources = {}            # analyzed path => Set<source path it read from>
  @digests = {}            # analyzed path => content digest 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>
end

Instance Method Details

#affected_closure(changed, added, removed) ⇒ 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.



107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/rigor/analysis/incremental_session.rb', line 107

def affected_closure(changed, added, removed)
  scan = changed + added
  new_fps = symbol_fingerprints_for(scan)
  new_class_decls = class_declarations_for(scan)
  changed_pairs = Incremental.changed_symbol_pairs(changed, @symbol_fingerprints, new_fps)
  base = if changed_pairs.any? || changed.any? { |f| @ancestry_dependents[f] }
           Incremental.affected_with_symbols(changed, changed_pairs, @symbol_dependents, @ancestry_dependents)
         else
           Incremental.affected(changed, @dependents)
         end
  closure = base | added.to_set | negative_affected(scan, new_fps, new_class_decls)
  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.



64
65
66
# File 'lib/rigor/analysis/incremental_session.rb', line 64

def analyzed_files
  @analyzed
end

#baselineObject

Full baseline analysis with recording. Returns the run’s diagnostics; populates the in-process cache + dependency state.



70
71
72
73
74
75
76
77
78
# File 'lib/rigor/analysis/incremental_session.rb', line 70

def baseline
  runner = build_runner(record_dependencies: true)
  diagnostics = run_runner(runner).diagnostics
  @analyzed = runner.analyzed_files
  absorb_dependency_graph(runner)
  @cache = per_file(diagnostics)
  @digests = @analyzed.to_h { |path| [path, digest(path)] }
  diagnostics
end

#current_filesObject

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



124
125
126
127
# File 'lib/rigor/analysis/incremental_session.rb', line 124

def current_files
  runner = build_runner
  @paths ? runner.analysis_file_set(@paths) : runner.analysis_file_set
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.



137
138
139
140
141
142
143
# File 'lib/rigor/analysis/incremental_session.rb', line 137

def reanalyze_subset(subset)
  affected = subset.to_set
  runner = build_runner(analyze_only: affected)
  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.



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/rigor/analysis/incremental_session.rb', line 84

def recheck
  previous = @analyzed
  current = current_files
  added = current - previous
  removed = previous - current
  changed = (current & previous).reject { |path| digest(path) == @digests[path] }
  affected = affected_closure(changed, added, removed)
  analyze_set = affected & current
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true)
  fresh = run_runner(runner).diagnostics
  reused = (current & previous) - affected.to_a
  merged = fresh + reused.flat_map { |path| @cache[path] || [] }
  absorb(runner, fresh, current, analyze_set, removed)
  Recheck.new(diagnostics: merged, changed: changed.to_set, affected: affected, reused: reused.to_set)
end

#run_incremental(snapshot:, fingerprint:) ⇒ 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.



153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/rigor/analysis/incremental_session.rb', line 153

def run_incremental(snapshot:, fingerprint:)
  restored = fingerprint && snapshot.load(fingerprint: fingerprint)
  if restored
    restore(restored)
    diagnostics = recheck.diagnostics
    warm = true
  else
    diagnostics = baseline
    warm = false
  end
  snapshot.save(fingerprint: fingerprint, payload: to_payload) if fingerprint
  [diagnostics, warm]
end