Class: Rigor::Analysis::Runner

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

Overview

rubocop:disable Metrics/ClassLength

Constant Summary collapse

RUBY_GLOB =
"**/*.rb"
DEFAULT_CACHE_ROOT =
".rigor/cache"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(configuration:, explain: false, cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT), plugin_requirer: nil, workers: 0, collect_stats: true, buffer: nil, prebuilt: nil, environment: nil, record_dependencies: false, record_self_calls: false, analyze_only: nil) ⇒ Runner

Returns a new instance of Runner.

Parameters:

  • configuration (Rigor::Configuration)
  • explain (Boolean) (defaults to: false)

    surface fail-soft fallback events as ‘:info` diagnostics.

  • cache_store (Rigor::Cache::Store, nil) (defaults to: Cache::Store.new(root: DEFAULT_CACHE_ROOT))

    the persistent cache the runner exposes to producers (‘RbsConstantTable` and successors). Pass `nil` to disable caching for this run; the CLI’s ‘–no-cache` flag wires `nil` through. v0.0.9 group A slice 1 introduces the surface; later slices route real producers through it.

  • workers (Integer) (defaults to: 0)

    ADR-15 Phase 4b — when greater than zero, per-file analysis dispatches across a pool of N Ractor workers built around WorkerSession. Default ‘0` keeps the sequential code path bit-for-bit unchanged. Phase 4c will wire the CLI / `.rigor.yml` surface that produces non-zero values; this slice leaves the parameter as a programmatic opt-in only.

  • collect_stats (Boolean) (defaults to: true)

    when true (default), ‘#run` builds a Rigor::Analysis::RunStats summary exposed via `result.stats` — this forces the RBS env build at end-of-run so the `class_decl_paths` snapshot has real source attribution. Set to false to skip the stats summary entirely; the CLI’s ‘–no-stats` threads `false` through to keep trivial-fixture runs from warming `.rigor/cache`.

  • prebuilt (Rigor::Analysis::ProjectScan, nil) (defaults to: nil)

    when supplied, the runner adopts the pre-built plugin registry / dependency-source index / scanner outputs from the snapshot and skips the per-call pre-passes that produce them. Used by long-lived integrations (‘Rigor::LanguageServer::ProjectContext`) to keep per-buffer requests fast — scanners walk the project once per generation rather than once per request, and plugin `#prepare` runs once per generation rather than once per request. Watched-file invalidation is the owner’s responsibility; the runner trusts the snapshot it was given.

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

    opt-in Environment override. When supplied, sequential mode uses the provided env instance in ‘#analyze_files` instead of building a fresh one via `Environment.for_project`, and attaches the runner’s per-run reporter pair onto the env’s mutable ‘Reporters` slot via `Environment#attach_reporters!`. Long-lived consumers (LSP `ProjectContext`) pass a shared env so per-publish work doesn’t repeat the ‘Environment.for_project` build (bundler / lockfile / collection discovery, RbsLoader construction). Pool mode ignores the override — each worker continues to build its own Environment.



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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/rigor/analysis/runner.rb', line 93

def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
               cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
               plugin_requirer: nil, workers: 0, collect_stats: true,
               buffer: nil, prebuilt: nil, environment: nil,
               record_dependencies: false, record_self_calls: false, analyze_only: nil)
  @configuration = configuration
  @explain = explain
  @cache_store = enforce_read_only_cache(cache_store, buffer)
  @plugin_requirer = plugin_requirer
  @workers = workers
  @collect_stats = collect_stats
  @buffer = buffer
  @prebuilt = prebuilt
  @environment_override = environment
  # ADR-46 slice 1 — opt-in cross-file dependency recording. Off by
  # default; when true, `analyze_file` records each file's
  # cross-file reads into `file_dependencies` (the incremental
  # cache, a later slice, consumes them).
  @record_dependencies = record_dependencies
  # ADR-24 slice 4a — opt-in unresolved-implicit-self-call recording.
  # Off by default; when true, `analyze_file` activates the engine
  # choke-point recorder and collects each file's misses into
  # `unresolved_self_calls` (a later closed-class-gated rule consumes
  # them). Purely observational — diagnostics are byte-identical.
  @record_self_calls = record_self_calls
  @unresolved_self_calls = {}
  # Memoised activation decision for the `call.self-undefined-method`
  # rule (nil = not yet computed). See `self_undefined_rule_active?`.
  @self_undefined_rule_active = nil
  @analyzed_files = [].freeze
  # In-memory source map for `#run_source` — `{ logical_path => source
  # String }`. When set, `parse_source` reads bytes from here instead
  # of disk and `expand_paths` accepts the (possibly non-existent)
  # logical path. nil on a normal disk-backed run.
  @in_memory_sources = nil
  # ADR-46 slice 2 — the subset-analysis hook. When set (a collection
  # of paths), the whole-project pre-pass still runs over every file
  # (so the cross-file index is complete), but only files in this set
  # are analyzed for diagnostics — the body tier re-analyses the
  # affected closure and serves the rest from the per-file cache.
  # `nil` (the default) analyzes everything.
  @analyze_only = analyze_only && Set.new(analyze_only)
  @file_dependencies = {}
  @plugin_registry = Plugin::Registry::EMPTY
  @dependency_source_index = DependencySourceInference::Index::EMPTY
  @rbs_extended_reporter = RbsExtended::Reporter.new
  @boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
  @source_rbs_synthesis_reporter = Plugin::SourceRbsSynthesisReporter.new
  # `#run` resets these for each invocation; pre-seed them to
  # empty containers so `build_run_stats` / `pre_file_diagnostics`
  # (private, called only from `#run`) can read them without
  # nil-guards. Kept inline (not a helper) so the engine's own
  # flow analysis sees the ivars established in the constructor.
  @class_decl_paths_snapshot = {}.freeze
  @signature_paths_snapshot = [].freeze
  @synthesized_namespaces_snapshot = [].freeze
  # `rigor:v1:conforms-to` results, snapshotted from the
  # per-run RBS env in `analyze_files_sequentially` (gated on
  # the project declaring `signature_paths:`) and drained by
  # `conforms_to_diagnostics`. Inline default per the comment
  # above so the engine's own flow analysis sees it seeded.
  @conformance_results_snapshot = [].freeze
  @cached_plugin_prepare_diagnostics = [].freeze
  @project_discovered_classes = {}.freeze
  @project_discovered_def_nodes = {}.freeze
  @project_discovered_def_sources = {}.freeze
  @project_discovered_superclasses = {}.freeze
  @project_discovered_includes = {}.freeze
  @project_discovered_class_sources = {}.freeze
  @project_discovered_method_visibilities = {}.freeze
  @project_discovered_methods = {}.freeze
  @project_data_member_layouts = {}.freeze
end

Instance Attribute Details

#analyzed_filesObject (readonly)

Returns the value of attribute analyzed_files.



42
43
44
# File 'lib/rigor/analysis/runner.rb', line 42

def analyzed_files
  @analyzed_files
end

#boundary_cross_reporterObject (readonly)

Returns the value of attribute boundary_cross_reporter.



42
43
44
# File 'lib/rigor/analysis/runner.rb', line 42

def boundary_cross_reporter
  @boundary_cross_reporter
end

#bufferObject (readonly)

ADR-pending editor mode — present when the runner is wired for the ‘–tmp-file` / `–instead-of` buffer-binding shape (`docs/design/20260516-editor-mode.md`). Nil for normal project runs.



171
172
173
# File 'lib/rigor/analysis/runner.rb', line 171

def buffer
  @buffer
end

#cache_storeObject (readonly)

Returns the value of attribute cache_store.



42
43
44
# File 'lib/rigor/analysis/runner.rb', line 42

def cache_store
  @cache_store
end

#dependency_source_indexObject (readonly)

Returns the value of attribute dependency_source_index.



42
43
44
# File 'lib/rigor/analysis/runner.rb', line 42

def dependency_source_index
  @dependency_source_index
end

#file_dependenciesObject (readonly)

Returns the value of attribute file_dependencies.



42
43
44
# File 'lib/rigor/analysis/runner.rb', line 42

def file_dependencies
  @file_dependencies
end

#plugin_registryObject (readonly)

Returns the value of attribute plugin_registry.



42
43
44
# File 'lib/rigor/analysis/runner.rb', line 42

def plugin_registry
  @plugin_registry
end

#rbs_extended_reporterObject (readonly)

Returns the value of attribute rbs_extended_reporter.



42
43
44
# File 'lib/rigor/analysis/runner.rb', line 42

def rbs_extended_reporter
  @rbs_extended_reporter
end

#unresolved_self_callsObject (readonly)

Returns the value of attribute unresolved_self_calls.



42
43
44
# File 'lib/rigor/analysis/runner.rb', line 42

def unresolved_self_calls
  @unresolved_self_calls
end

Instance Method Details

#analysis_file_set(paths = @configuration.paths) ⇒ Object

ADR-46 — the project file set that a run over ‘paths` would analyze, computed by globbing only (no RBS environment build), so the incremental fingerprint can be derived cheaply on the warm path before deciding whether to build the env at all.



234
235
236
# File 'lib/rigor/analysis/runner.rb', line 234

def analysis_file_set(paths = @configuration.paths)
  expand_paths(paths).fetch(:files)
end

#analyze_files(files, environment: nil) ⇒ Object

ADR-15 Phase 4b — routes per-file analysis to either the sequential coordinator-side Environment (legacy path, default) or a Ractor worker pool built around WorkerSession (opt-in via ‘workers:`). The sequential path is bit-for-bit unchanged from v0.1.4 / earlier; the pool path is the substrate exercised by phase 4c when `RIGOR_RACTOR_WORKERS` / `.rigor.yml` `parallel.workers:` is wired.

Sequential mode also snapshots ‘class_decl_paths` from the local environment after the per-file loop completes so `RunStats` can attribute the RBS class universe between project-sig and bundled sources. The env stays a LOCAL variable (not an ivar) so it goes GC-eligible when the method returns — holding it as long-lived state added memory pressure that surfaced as a Bus Error during the spec suite under Ruby 4.0 + rbs 4.0.2.



547
548
549
550
551
552
# File 'lib/rigor/analysis/runner.rb', line 547

def analyze_files(files, environment: nil)
  return [] if files.empty?
  return dispatch_pool(files) if pool_mode?

  analyze_files_sequentially(files, environment || resolve_sequential_environment(source_files: files))
end

#analyze_files_sequentially(files, environment) ⇒ Object



554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
# File 'lib/rigor/analysis/runner.rb', line 554

def analyze_files_sequentially(files, environment)
  # Snapshot the small synthesized-namespace name list (NOT the
  # env — see the method comment) so #run can surface the
  # malformed-RBS `:info` diagnostic without rebuilding the env.
  # Gated on the project actually declaring `signature_paths:`:
  # synthesis only matters for the project's own RBS, and
  # `#synthesized_namespaces` forces the (otherwise-lazy) RBS env
  # to build — doing so when there is no project sig set would
  # warm `.rigor/cache` on a bare `--no-stats` run.
  @synthesized_namespaces_snapshot =
    project_signature_paths? ? (environment.rbs_loader&.synthesized_namespaces || []) : []
  # `rigor:v1:conforms-to` lives only in the project's own
  # `signature_paths:` RBS, so gate the scan the same way and
  # reuse the already-built env (no extra RBS load).
  @conformance_results_snapshot =
    project_signature_paths? ? RbsExtended::ConformanceChecker.scan(environment.rbs_loader) : []
  result = files.flat_map { |path| analyze_file(path, environment) }
  if @collect_stats
    loader = environment.rbs_loader
    @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
    @signature_paths_snapshot = loader&.signature_paths || [].freeze
  end
  result
end

#analyzed_file_entries(expansion) ⇒ Object



395
396
397
398
399
400
401
402
# File 'lib/rigor/analysis/runner.rb', line 395

def analyzed_file_entries(expansion)
  expansion.fetch(:files).map do |path|
    physical = @buffer ? @buffer.resolve(path) : path
    Cache::Descriptor::FileEntry.new(
      path: physical, comparator: :digest, value: Digest::SHA256.file(physical).hexdigest
    )
  end
end

#assemble_run_diagnostics(expansion, environment: nil) ⇒ Object



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/rigor/analysis/runner.rb', line 323

def assemble_run_diagnostics(expansion, environment: nil)
  diagnostics = pre_file_diagnostics(expansion)
  # ADR-46 — record which project files this run actually analyzed
  # (the `analyze_only` subset, or all of them). The incremental
  # orchestrator serves every analyzed-but-not-affected file from the
  # per-file cache, so it needs the full analyzed set to subtract the
  # affected closure from.
  targets = target_files(expansion)
  @analyzed_files = targets
  diagnostics += analyze_files(targets, environment: environment)
  diagnostics += rbs_synthesized_namespace_diagnostics
  diagnostics += conforms_to_diagnostics
  diagnostics += rbs_extended_reporter_diagnostics
  diagnostics += boundary_cross_diagnostics
  diagnostics + source_rbs_synthesis_diagnostics
end

#class_declarationsObject

ADR-46 slice 3 — per-file set of the qualified class/module names declared in that file. Used to detect a class that appeared in an edit so a subclass whose ancestor was previously undefined (and so recorded a negative class edge) is re-checked. Inverts the project class-source attribution (class → declaring files).



281
282
283
284
285
286
287
# File 'lib/rigor/analysis/runner.rb', line 281

def class_declarations
  result = Hash.new { |hash, key| hash[key] = Set.new }
  @project_discovered_class_sources.each do |class_name, files|
    files.each { |file| result[file] << class_name }
  end
  result.transform_values(&:freeze).freeze
end

#compute_run_diagnostics(expansion) ⇒ Object

ADR-45 — unchanged-project fast path. Serves the whole run’s (pre-severity-profile) diagnostics from one record-and-validate cache entry when every file the previous run read is unchanged, skipping the dominant per-file inference. The dependency set is collected AFTER the run (so it captures files the plugins read mid-analysis, e.g. a Pundit policy) and re-validated on the next run; the entry is keyed on the inputs known up front (config, gem / engine versions, analyzed-path set).



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/rigor/analysis/runner.rb', line 297

def compute_run_diagnostics(expansion)
  @run_served_from_cache = false
  return assemble_run_diagnostics(expansion) unless run_result_cacheable?

  environment = resolve_sequential_environment(source_files: target_files(expansion))
  rbs_descriptor = environment&.rbs_loader ? Cache::RbsDescriptor.build(environment.rbs_loader) : Cache::Descriptor.new
  key_descriptor = run_key_descriptor(expansion, rbs_descriptor)
  return assemble_run_diagnostics(expansion, environment: environment) if key_descriptor.nil?

  computed = false
  diagnostics = @cache_store.fetch_or_validate(
    producer_id: "analysis.run-diagnostics", key_descriptor: key_descriptor
  ) do
    computed = true
    diags = assemble_run_diagnostics(expansion, environment: environment)
    [diags, run_dependency_descriptor(expansion, rbs_descriptor)]
  end
  @run_served_from_cache = !computed
  diagnostics
rescue StandardError
  # The result cache must never break a run. If anything in the
  # cache path fails, fall back to a direct, uncached analysis.
  @run_served_from_cache = false
  assemble_run_diagnostics(expansion)
end

#config_hash_entry(key, payload) ⇒ Object



404
405
406
# File 'lib/rigor/analysis/runner.rb', line 404

def config_hash_entry(key, payload)
  Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
end

#diagnostic_from_hash(hash) ⇒ Object



669
670
671
672
673
674
675
# File 'lib/rigor/analysis/runner.rb', line 669

def diagnostic_from_hash(hash)
  Diagnostic.new(
    path: hash.fetch(:path), line: hash.fetch(:line), column: hash.fetch(:column),
    message: hash.fetch(:message), severity: hash.fetch(:severity),
    rule: hash.fetch(:rule), source_family: :builtin
  )
end

#file_dependentsObject

ADR-46 §2 — inverts #file_dependencies into the reverse edge the incremental step walks: ‘dependents = { A : A read a declaration / body from X }`. On an edit to X, the body tier (slice 2) re-analyses `X ∪ dependents` and serves every other file from the per-file cache. Built on demand from the recorded `sources` sets (so it reflects whatever `analyze_file` captured —empty unless the runner was constructed with `record_dependencies: true`). The negative (`missing`) edges are NOT inverted here: they feed the structural tier (slice 3), which re-checks a consumer when a name it looked up and did not resolve later appears.



249
250
251
# File 'lib/rigor/analysis/runner.rb', line 249

def file_dependents
  Incremental.invert(@file_dependencies.transform_values(&:sources))
end

#pre_eval_diagnosticsObject

ADR-17 slice 1 — surface a ‘:error` diagnostic for each `pre_eval:` entry whose resolved path doesn’t exist on disk. Loud failure mode (‘:error`, not `:warning`): a missing pre_eval path is a configuration mistake the user must fix before analysis is meaningful.

Slice 2 adds the ‘:warning` `pre-eval.parse-error` stream from the pre-pass scanner — accumulated as `@pre_eval_diagnostics_from_scanner` during #run and merged here so both diagnostics flow through the same severity / ordering pipeline.



652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
# File 'lib/rigor/analysis/runner.rb', line 652

def pre_eval_diagnostics
  not_found = @configuration.pre_eval.filter_map do |path|
    next if File.file?(path)

    Diagnostic.new(
      path: ".rigor.yml", line: 1, column: 1,
      message: "pre_eval entry not found: #{path.inspect}. " \
               "Pre-evaluation requires the file to exist on disk; remove the entry " \
               "or create the file before re-running analysis.",
      severity: :error,
      rule: "pre-eval.file-not-found",
      source_family: :builtin
    )
  end
  not_found + Array(@pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
end

#pre_file_diagnostics(expansion) ⇒ Object

Pre-file diagnostic streams that fire once per run rather than per analyzed file: plugin load / prepare envelopes, the ADR-10 dependency-source resolution surface, and the ‘expand_paths` errors for `paths:` entries that don’t exist or aren’t ‘.rb`. Aggregated here so `#run` stays under the ABC budget.

ADR-15 Phase 4b — ‘plugin_prepare_diagnostics` runs on the coordinator’s plugin registry under sequential mode; under pool mode each worker re-runs ‘prepare` against its own plugin instances, so the pool path drains the first worker’s prepare-diagnostic snapshot into the aggregated diagnostic stream instead (see #analyze_files_in_pool). Skipping the coordinator prepare in pool mode avoids double-running ‘#prepare` against the coordinator-side plugin instances (which the pool path never consults for per-file analysis).



613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
# File 'lib/rigor/analysis/runner.rb', line 613

def pre_file_diagnostics(expansion)
  # ADR-18 slice 3 — prepare diagnostics are captured
  # earlier in #run (before the synthetic-method scanner)
  # so cross-plugin facts are available to the scanner.
  # We re-surface the captured diagnostics here so the
  # existing pre_file_diagnostics ordering is preserved.
  prepare = pool_mode? ? [] : @cached_plugin_prepare_diagnostics
  plugin_load_diagnostics +
    prepare +
    pre_eval_diagnostics +
    dependency_source_diagnostics +
    dependency_source_budget_diagnostics +
    dependency_source_config_conflict_diagnostics +
    rbs_coverage_diagnostics +
    expansion.fetch(:errors)
end

#prepare_project_scan(paths: @configuration.paths) ⇒ Object

Runs every project-wide pre-pass (‘load_plugins` + `plugin#prepare` + dependency-source builder + synthetic-method scanner + project-patched scanner) exactly once, then returns a frozen ProjectScan snapshot.

Long-lived integrations (‘Rigor::LanguageServer::ProjectContext`) call this once per project-state generation and feed the snapshot back into `Runner.new(prebuilt: scan)` for every subsequent per-buffer publish. The cold pre-pass cost is paid once per generation rather than once per keystroke.

Notes for callers:

  • The runner this method is called on may be a “build only” instance — ‘@buffer` is typically nil so the scanners observe on-disk bytes for the full project. Callers that want pre-passes to see a particular buffer’s edits should build the runner WITH ‘buffer:` set.

  • The returned ProjectScan is frozen and shareable; the underlying ‘plugin_registry` is the same object that ran `#prepare`, so the per-plugin `services.fact_store` is already populated for subsequent dispatch use.



430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/rigor/analysis/runner.rb', line 430

def prepare_project_scan(paths: @configuration.paths)
  expansion = expand_paths(paths)
  run_project_pre_passes(expansion: expansion)
  ProjectScan.new(
    plugin_registry: @plugin_registry,
    dependency_source_index: @dependency_source_index,
    synthetic_method_index: @synthetic_method_index,
    project_patched_methods: @project_patched_methods,
    plugin_prepare_diagnostics: @cached_plugin_prepare_diagnostics.dup.freeze,
    pre_eval_diagnostics: @pre_eval_diagnostics_from_scanner.dup.freeze
  )
end

#run(paths = @configuration.paths) ⇒ Object

Walks every Ruby file under ‘paths`, parses it, builds a per-node scope index through `Rigor::Inference::ScopeIndexer`, and runs the `Rigor::Analysis::CheckRules` catalogue over it. Returns a `Rigor::Analysis::Result` aggregating every produced diagnostic plus any Prism parse errors. The Environment is built once at run start through `Environment.for_project` so all files share the same RBS load.



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/rigor/analysis/runner.rb', line 181

def run(paths = @configuration.paths)
  Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
    @configuration.fold_platform_specific_paths

  wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  target_ruby_error = validate_target_ruby
  return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error

  expansion = expand_paths(paths)
  @class_decl_paths_snapshot = {}.freeze
  @signature_paths_snapshot = []
  @synthesized_namespaces_snapshot = []
  @conformance_results_snapshot = []

  if @prebuilt
    adopt_prebuilt_project_scan(@prebuilt)
  else
    run_project_pre_passes(expansion: expansion)
  end

  diagnostics = compute_run_diagnostics(expansion)

  Result.new(
    diagnostics: apply_severity_profile(diagnostics),
    stats: stats_for_run(wall_started_at: wall_started_at, expansion: expansion)
  )
end

#run_dependency_descriptor(expansion, rbs_descriptor) ⇒ Object

Files the run actually depended on, collected AFTER it ran: every analyzed file, every RBS ‘sig` file (`rbs_descriptor.files`), and every file each plugin read (complete post-run, so reads made mid-analysis are included). Re-digested on the next run by Descriptor#fresh?.



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

def run_dependency_descriptor(expansion, rbs_descriptor)
  entries = analyzed_file_entries(expansion) + rbs_descriptor.files
  @plugin_registry.plugins.each do |plugin|
    # Read the boundary WITHOUT triggering its lazy `@io_boundary ||=`
    # initializer: plugin instances are frozen after the run, and a
    # plugin that never built a boundary read no files through it, so
    # it contributes no dependencies.
    boundary = plugin.instance_variable_get(:@io_boundary)
    entries.concat(boundary.cache_descriptor.files) if boundary
  end
  Cache::Descriptor.new(files: entries)
end

#run_key_descriptor(expansion, rbs_descriptor) ⇒ Object

Stable cache key inputs — known before the run: a digest of the resolved configuration, the engine + rbs versions + ‘–explain`, and the analyzed-path SET (adding/removing a file changes the key; editing one is caught by dependency validation). nil disables the cache for this run rather than risking a malformed key.



364
365
366
367
368
369
370
371
372
373
374
375
# File 'lib/rigor/analysis/runner.rb', line 364

def run_key_descriptor(expansion, rbs_descriptor)
  Cache::Descriptor.new(
    gems: rbs_descriptor.gems,
    configs: rbs_descriptor.configs + [
      config_hash_entry("configuration", Marshal.dump(@configuration.to_h)),
      config_hash_entry("engine", "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{@explain}"),
      config_hash_entry("paths", expansion.fetch(:files).sort.join("\n"))
    ]
  )
rescue StandardError
  nil
end

#run_result_cacheable?Boolean

Cacheable only for a full sequential project run with a writable cache and no per-buffer / prebuilt override — every other mode has a different result identity (pool workers read in separate processes; editor mode is per-buffer; prebuilt is the LSP path).

Returns:

  • (Boolean)


354
355
356
357
# File 'lib/rigor/analysis/runner.rb', line 354

def run_result_cacheable?
  !@cache_store.nil? && !@cache_store.read_only? &&
    @buffer.nil? && @prebuilt.nil? && !pool_mode?
end

#run_source(source:, path: "(source).rb") ⇒ Result

Analyze a single source String in memory, without writing it to disk — a clean entry point for embedders (LSP / editor mode) and a faster spec path than the per-call tmpdir + chdir. The source is bound to ‘path` (purely a logical identity carried in diagnostic locations; it need not exist on disk). The full run machinery still runs — environment build, plugin `prepare`, severity profile — so the result matches a one-file disk run; only the cross-file project pre-pass is empty (there is one file, and the per-file indexer self-discovers its own classes / defs).

Parameters:

  • source (String)

    Ruby source to analyze.

  • path (String) (defaults to: "(source).rb")

    logical path for diagnostic locations.

Returns:



223
224
225
226
227
228
# File 'lib/rigor/analysis/runner.rb', line 223

def run_source(source:, path: "(source).rb")
  @in_memory_sources = { path => source }
  run([path])
ensure
  @in_memory_sources = nil
end

#shared_fact_storeObject

Returns the per-run shared ‘Plugin::FactStore` instance. All loaded plugins share this store through their respective `Plugin::Services` (the same instance is threaded by `Plugin::Loader.load`). Returns `nil` when no plugins are loaded.



635
636
637
638
639
# File 'lib/rigor/analysis/runner.rb', line 635

def shared_fact_store
  return nil if @plugin_registry.nil? || @plugin_registry.empty?

  @plugin_registry.plugins.first&.services&.fact_store
end

#stats_for_run(wall_started_at:, expansion:) ⇒ Object

A cache hit skipped the analysis, so the per-run stats (wall split, RBS-class counts, …) were never gathered — report none rather than the stale snapshot defaults.



343
344
345
346
347
348
# File 'lib/rigor/analysis/runner.rb', line 343

def stats_for_run(wall_started_at:, expansion:)
  return nil unless @collect_stats
  return nil if @run_served_from_cache

  build_run_stats(wall_started_at: wall_started_at, expansion: expansion)
end

#symbol_fingerprintsObject

ADR-46 slice 4 — per-symbol body fingerprints, computed from the project pre-pass def index. Returns a frozen hash of the form:

{ "path/to/file.rb" => { "ClassName#method" => sha256_hex, … }, … }

Used by IncrementalSession to detect which symbols in a changed file actually changed bodies, so only callers of those specific symbols are re-checked. Only meaningful after a run that populated ‘@project_discovered_def_nodes` (i.e. any full or subset analysis); returns an empty frozen hash before the first run.



261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/rigor/analysis/runner.rb', line 261

def symbol_fingerprints
  result = Hash.new { |h, k| h[k] = {} }
  @project_discovered_def_sources.each do |class_name, methods|
    methods.each do |method_sym, path_line|
      path = path_line.split(":", 2).first
      node = @project_discovered_def_nodes.dig(class_name, method_sym)
      next unless node

      result[path]["#{class_name}##{method_sym}"] =
        Digest::SHA256.hexdigest(node.location.slice)
    end
  end
  result.transform_values(&:freeze).freeze
end

#validate_target_rubyObject

‘target_ruby` flows through to Prism’s ‘version:` option. Prism enforces the supported range and raises `ArgumentError` for versions it does not recognise. Run a one-time smoke parse here so a misconfigured target_ruby surfaces as a single project-level diagnostic instead of crashing the whole run on the first file.



683
684
685
686
687
688
689
690
691
692
693
694
# File 'lib/rigor/analysis/runner.rb', line 683

def validate_target_ruby
  Prism.parse("nil", version: @configuration.target_ruby)
  nil
rescue ArgumentError => e
  Diagnostic.new(
    path: ".rigor.yml", line: 1, column: 1,
    message: "target_ruby #{@configuration.target_ruby.inspect} is not accepted by Prism: #{e.message}",
    severity: :error,
    rule: "configuration-error",
    source_family: :builtin
  )
end