Class: Rigor::Analysis::Runner

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/analysis/runner.rb,
lib/rigor/analysis/runner/run_snapshots.rb,
lib/rigor/analysis/runner/pool_coordinator.rb,
lib/rigor/analysis/runner/project_pre_passes.rb,
lib/rigor/analysis/runner/diagnostic_aggregator.rb,
sig/rigor.rbs

Overview

rubocop:disable Metrics/ClassLength

Defined Under Namespace

Classes: DiagnosticAggregator, PoolCoordinator, ProjectPrePasses, RunSnapshots

Constant Summary collapse

DEFAULT_CACHE_ROOT =

Returns:

  • (String)
".rigor/cache"
RETURN_SUMMARY_KEYS_PER_DEF =

ADR-89 WD2 — bounds on the persisted return summaries (per-def observed call keys, and total defs summarised) so the incremental snapshot stays small. A def observed under more keys than the per-def cap keeps only the first; a def past the total cap is dropped (its symbol dependents then always re-check — the conservative direction).

8
RETURN_SUMMARY_TOTAL_CAP =
4000
PRISM_VERSION_LADDER =

Ruby versions probed (ascending) to discover the lowest one this Prism build accepts for version:. Prism exposes no version list, so the floor is found empirically — only when a misconfigured target_ruby is rejected — so the diagnostic can name it instead of leaving the user to guess (the dogfood field trial, 20260620-skill-driven-onboarding-dogfood.md, burned cycles on exactly this).

%w[
  3.0.0 3.1.0 3.2.0 3.3.0 3.4.0 3.5.0 4.0.0 4.1.0 4.2.0
].freeze
RUBY_GLOB =

Returns:

  • (String)

Instance Attribute Summary collapse

Class Method 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, seed_bundles: nil, collect_seed_bundles: false) ⇒ 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 workers. Default 0 keeps the sequential code path bit-for-bit unchanged. Controlled via the RIGOR_RACTOR_WORKERS env var or .rigor.yml parallel.workers: (Phase 4c, fully wired).

  • 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.

  • configuration: (Configuration)
  • explain: (Boolean) (defaults to: false)
  • cache_store: (Object) (defaults to: Cache::Store.new(root: DEFAULT_CACHE_ROOT))
  • plugin_requirer: (Object) (defaults to: nil)
  • workers: (Integer) (defaults to: 0)
  • collect_stats: (Boolean) (defaults to: true)
  • buffer: (Object) (defaults to: nil)
  • prebuilt: (Object) (defaults to: nil)
  • environment: (Object) (defaults to: nil)
  • record_dependencies: (Boolean) (defaults to: false)
  • analyze_only: (Object) (defaults to: nil)


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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/rigor/analysis/runner.rb', line 98

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,
               seed_bundles: nil, collect_seed_bundles: false)
  @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)
  # ADR-85 WD2 — seed-bundle discovery. When `collect_seed_bundles`, the cross-file discovery pre-pass
  # rebuilds from the prior run's per-file bundles (`@restored_seed_bundles`, re-walking only changed
  # files) instead of parsing every file, and exposes the refreshed set via `#seed_bundles` for the
  # session to persist. Off by default — a plain `rigor check` keeps today's parse+walk.
  @collect_seed_bundles = collect_seed_bundles
  @restored_seed_bundles = seed_bundles || {}
  @seed_bundles = {}.freeze
  @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. The
  # four end-of-pass snapshots (RBS class / signature-path tables, synthesized-namespace names,
  # `rigor:v1:conforms-to` results) live in one shared mutable {RunSnapshots} sink so the analysis
  # path that writes them and the run / aggregator code that reads them stay in separate
  # collaborators without a back-reference cycle.
  @snapshots = RunSnapshots.new
  @cached_plugin_prepare_diagnostics = [].freeze
  @project_discovered_classes = {}.freeze
  @project_discovered_def_nodes = {}.freeze
  @project_discovered_singleton_def_nodes = {}.freeze
  @project_discovered_def_sources = {}.freeze
  @project_discovered_singleton_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
  @project_struct_member_layouts = {}.freeze
  # ADR-67 WD6a — the call-site parameter-inference table, populated by the opt-in pre-pass in
  # `assemble_run_diagnostics` when `parameter_inference:` is enabled, then seeded onto every per-file
  # scope (sequential + fork-worker) through `project_scope_seed_tables`. Empty by default, so the gate-off
  # run carries no table and is byte-identical.
  @project_param_inferred_types = {}.freeze
  # ADR-84 WD2 — per-run identity token for the user-method return memo's bucket (see
  # Scope::DiscoveryIndex#run_generation). Minted fresh in `run_analysis` so the memo never serves an
  # entry across a run boundary (LSP re-check, ADR-62 warm loop); nil until the first run so
  # runner-less probes keep the per-file fallback.
  @run_generation = nil
  build_collaborators
end

Instance Attribute Details

#analyzed_filesArray[String] (readonly)

Returns the value of attribute analyzed_files.

Returns:

  • (Array[String])


56
57
58
# File 'lib/rigor/analysis/runner.rb', line 56

def analyzed_files
  @analyzed_files
end

#boundary_cross_reporterObject (readonly)

Returns the value of attribute boundary_cross_reporter.



56
57
58
# File 'lib/rigor/analysis/runner.rb', line 56

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.

Returns:

  • (Object)


184
185
186
# File 'lib/rigor/analysis/runner.rb', line 184

def buffer
  @buffer
end

#cache_storeObject (readonly)

Rigor::Cache::Store itself is not yet sig-covered (the cache namespace is in UNSIGNED_NAMESPACES per spec/rigor/public_api_drift_spec.rb), so reference it as untyped until the full Cache::Store sig lands. Steep otherwise raises RBS::UnknownTypeName for the named type.

Returns:

  • (Object)


113
114
115
# File 'sig/rigor.rbs', line 113

def cache_store
  @cache_store
end

#dependency_source_indexObject (readonly)

Returns the value of attribute dependency_source_index.



56
57
58
# File 'lib/rigor/analysis/runner.rb', line 56

def dependency_source_index
  @dependency_source_index
end

#file_dependenciesHash[String, untyped] (readonly)

ADR-46 — the per-file cross-file read records this run captured (empty unless record_dependencies: true). Sequential analysis records into @file_dependencies via #analyze_file; the fork pool records per-worker and marshals the records into the coordinator, so a pooled --incremental recheck refreshes the same dependency graph a sequential recheck would. A run is either sequential OR pooled for a given file set, so the two maps never carry the same key.

Returns:

  • (Hash[String, untyped])


65
66
67
68
# File 'lib/rigor/analysis/runner.rb', line 65

def file_dependencies
  pooled = @pool_coordinator.collected_dependencies
  pooled.empty? ? @file_dependencies : @file_dependencies.merge(pooled)
end

#plugin_registryObject (readonly)

Returns the value of attribute plugin_registry.

Returns:

  • (Object)


56
57
58
# File 'lib/rigor/analysis/runner.rb', line 56

def plugin_registry
  @plugin_registry
end

#rbs_extended_reporterObject (readonly)

Returns the value of attribute rbs_extended_reporter.



56
57
58
# File 'lib/rigor/analysis/runner.rb', line 56

def rbs_extended_reporter
  @rbs_extended_reporter
end

#seed_bundlesObject (readonly)

Returns the value of attribute seed_bundles.



56
57
58
# File 'lib/rigor/analysis/runner.rb', line 56

def seed_bundles
  @seed_bundles
end

#unresolved_self_callsObject (readonly)

Returns the value of attribute unresolved_self_calls.



56
57
58
# File 'lib/rigor/analysis/runner.rb', line 56

def unresolved_self_calls
  @unresolved_self_calls
end

Class Method Details

.prism_supported_floorString?

Returns the lowest target_ruby this Prism build accepts, or nil if none of the ladder parses. Memoised per process (the value is constant for a given Prism).

Returns:

  • (String, nil)

    the lowest target_ruby this Prism build accepts, or nil if none of the ladder parses. Memoised per process (the value is constant for a given Prism).



715
716
717
718
719
720
721
722
# File 'lib/rigor/analysis/runner.rb', line 715

def self.prism_supported_floor
  @prism_supported_floor ||= PRISM_VERSION_LADDER.find do |candidate|
    Prism.parse("nil", version: candidate)
    true
  rescue ArgumentError
    false
  end
end

Instance Method Details

#analysis_file_set(paths = @configuration.paths) ⇒ Array[String]

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.

Parameters:

  • paths (Array[String]) (defaults to: @configuration.paths)

Returns:

  • (Array[String])


261
262
263
# File 'lib/rigor/analysis/runner.rb', line 261

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

#analyzed_file_entries(expansion) ⇒ Object



581
582
583
584
585
586
587
588
# File 'lib/rigor/analysis/runner.rb', line 581

def analyzed_file_entries(expansion)
  expansion.fetch(:files).map do |path|
    physical = @buffer ? @buffer.resolve(path) : path
    # ADR-87 WD1 — validation-only dependency descriptor, so the stat-then-digest `:stat` comparator
    # applies (the env-cache KEY files stay `:digest`).
    Cache::Descriptor::FileEntry.stat(path: physical, digest: Cache::FileDigest.hexdigest(physical))
  end
end

#assemble_run_diagnostics(expansion, environment: nil) ⇒ Object



473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
# File 'lib/rigor/analysis/runner.rb', line 473

def assemble_run_diagnostics(expansion, environment: nil)
  # Force the deferred cross-file discovery pre-pass on the analysis (miss) path. Memoised, so the
  # eager force in `#run` (recording / subset modes) makes this a no-op. A warm cache HIT never calls
  # `assemble_run_diagnostics`, so it never runs the two whole-project parse passes. Runs over the FULL
  # expansion — subset (`analyze_only`) mode still needs the complete cross-file index (ADR-46 §2).
  ensure_project_discovery(expansion)
  # ADR-67 WD6a — the opt-in call-site parameter-inference pre-pass. Runs on the parent BEFORE the pool
  # split (so every worker sees the same frozen table — the seed-before-fork determinism the discovery
  # tables use) and only on the analysis (miss / non-cacheable) path (a warm ADR-45 cache HIT never
  # assembles). Gate off → no-op, and `environment` is left untouched so its lazy build timing is
  # unchanged. Returns the resolved environment so the sequential dispatch reuses it (no double build).
  environment = seed_parameter_inference(expansion, environment)
  diagnostics = @diagnostic_aggregator.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 += @pool_coordinator.analyze_files(targets, environment: environment)
  diagnostics += @diagnostic_aggregator.rbs_quarantined_signature_diagnostics
  diagnostics += @diagnostic_aggregator.rbs_environment_build_failed_diagnostics
  diagnostics += @diagnostic_aggregator.rbs_synthesized_namespace_diagnostics
  diagnostics += @diagnostic_aggregator.conforms_to_diagnostics
  diagnostics += @diagnostic_aggregator.rbs_extended_reporter_diagnostics
  diagnostics += @diagnostic_aggregator.boundary_cross_diagnostics
  diagnostics + @diagnostic_aggregator.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).



319
320
321
322
323
324
325
# File 'lib/rigor/analysis/runner.rb', line 319

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

#collect_return_summaries(result, nodes, sources, separator, memo, effects_evaluator) ⇒ Object

Folds one def-node/def-source table pair's memo entries into the return-summary result under separator. A live def node (a cold / re-walked file) is the identity every cross-file caller resolved through, so memo[node] holds those callers' observed keys; a DefHandle (an unchanged file on the warm path) never keyed a memo entry, so it is skipped (its summary is carried over from the snapshot). Bounded per def and in total so the snapshot stays small.



353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/rigor/analysis/runner.rb', line 353

def collect_return_summaries(result, nodes, sources, separator, memo, effects_evaluator)
  nodes.each do |class_name, methods|
    methods.each do |method_name, node|
      next if node.is_a?(Inference::DefHandle)

      entries = memo[node]
      next if entries.nil? || entries.empty?

      path_line = sources.dig(class_name, method_name)
      next if path_line.nil?

      break if result.size >= RETURN_SUMMARY_TOTAL_CAP

      capped = entries.first(RETURN_SUMMARY_KEYS_PER_DEF)
      result[[path_line.split(":", 2).first, "#{class_name}#{separator}#{method_name}"]] = {
        keys: capped.map { |entry| [entry.receiver, entry.arg_types] },
        returns: capped.map { |entry| entry.result.describe(:short) },
        effects: content_effects(effects_evaluator, node)
      }
    end
  end
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).



440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
# File 'lib/rigor/analysis/runner.rb', line 440

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

  environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
  # Lazy-files descriptor: the cache KEY reads only `gems` + `configs`; the RBS signature-tree `files`
  # are digested solely by `run_dependency_descriptor` on a MISS, so a warm HIT never walks the tree.
  rbs_descriptor = if environment&.rbs_loader
                     Cache::RbsDescriptor.build_run(environment.rbs_loader)
                   else
                     Cache::Descriptor.new
                   end
  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: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID, key_descriptor: key_descriptor,
    generation_cap: RunCacheKey::GENERATION_CAP
  ) 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

#content_effects(effects_evaluator, node) ⇒ Object

The content-mutated parameter positions of node, or [] if the computation raises (defensive: the effects surface only routes attention, never soundness — a missing set reads as "no floor").



378
379
380
381
382
# File 'lib/rigor/analysis/runner.rb', line 378

def content_effects(effects_evaluator, node)
  effects_evaluator.content_mutated_parameter_positions(node)
rescue StandardError
  []
end

#evaluate_return_types(paths, specs) ⇒ Hash

ADR-89 WD2 — re-evaluate the return type of specific project methods at previously-observed call keys, WITHOUT analyzing any file. The incremental session calls this session-side (before dispatching the recheck) to prove a declaration-stable, changed callee returns the same type at every key its baseline callers used, so those callers' re-analysis can be skipped. Builds the cross-file discovery index (the ADR-85 seed-bundle fold, re-walking only edited files) and the RBS environment (served from the cache store) exactly as a real run's setup does, then re-drives each spec's def through the ADR-84 return memo. Off any hot path — invoked only when declaration-stable changed pairs carry a persisted summary.

Parameters:

  • paths (Array<String>, nil)

    analysis roots (nil → the configuration's paths:).

  • specs (Array<Hash>)

    each { class_name:, method_name:, singleton:, keys: [[receiver, args], …] }.

Returns:

  • (Hash)

    { [class_name, method_name, singleton] => [return_descriptor_or_nil, …] }.



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

def evaluate_return_types(paths, specs)
  return {} if specs.empty?

  Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
    Inference::DefNodeResolver.with_run { evaluate_return_types_setup(paths, specs) }
  end
end

#evaluate_return_types_setup(paths, specs) ⇒ Object

Builds the discovery index + environment (a real run's prologue, minus per-file analysis) and re-evaluates each spec's def. Extracted so #evaluate_return_types's with_run wrappers stay thin.



405
406
407
408
409
410
411
412
413
414
415
# File 'lib/rigor/analysis/runner.rb', line 405

def evaluate_return_types_setup(paths, specs)
  expansion = expand_paths(paths || @configuration.paths)
  @project_discovery_done = false
  @run_generation = Object.new.freeze
  run_project_pre_passes(expansion: expansion)
  ensure_project_discovery(expansion)
  environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
  specs.to_h do |spec|
    [[spec[:class_name], spec[:method_name], spec[:singleton]], evaluate_spec_returns(spec, environment)]
  end
end

#evaluate_spec_returns(spec, environment) ⇒ Object

Re-evaluates one spec's def at each of its observed keys. Locates the (live) def node in the discovery index, builds a project-seeded scope, and returns one return descriptor per key (nil when the def is missing / bodyless or the memo refuses a transient result — the caller reads either as "not provably stable" and keeps the dependents).



421
422
423
424
425
426
427
428
429
430
431
432
# File 'lib/rigor/analysis/runner.rb', line 421

def evaluate_spec_returns(spec, environment)
  table = spec[:singleton] ? @project_discovered_singleton_def_nodes : @project_discovered_def_nodes
  node = table.dig(spec[:class_name], spec[:method_name])
  node = Inference::DefNodeResolver.resolve(node) if node.is_a?(Inference::DefHandle)
  return spec[:keys].map { nil } if node.nil?

  scope = seed_project_scope(Scope.empty(environment: environment, source_path: spec[:path]))
  spec[:keys].map do |(receiver, arg_types)|
    result = scope.user_method_return(node, receiver, arg_types)
    result&.describe(:short)
  end
end

#file_dependentsHash[String, untyped]

ADR-46 §2 — inverts #file_dependencies into the reverse edge the incremental step walks: dependents[X] = { A : A read a declaration / body from X }. On an edit to X, the body tier (slice 2) re-analyses {X} ∪ dependents[X] 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.

Returns:

  • (Hash[String, untyped])


272
273
274
# File 'lib/rigor/analysis/runner.rb', line 272

def file_dependents
  Incremental.invert(file_dependencies.transform_values(&:sources))
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.

Parameters:

  • paths: (Array[String]) (defaults to: @configuration.paths)

Returns:

  • (Object)


612
613
614
615
616
617
# File 'lib/rigor/analysis/runner.rb', line 612

def prepare_project_scan(paths: @configuration.paths)
  expansion = expand_paths(paths)
  result = @pre_passes.run(expansion: expansion)
  apply_pre_passes_result(result)
  @pre_passes.build_project_scan(result)
end

#return_summariesHash

ADR-89 WD2 — the per-method observed-key return summaries the run's ADR-84 return memo just captured. For each project method with live memo entries, a bounded { keys:, returns:, effects: } summary the incremental session persists: keys the observed [receiver, arg_types] type tuples, returns their describe(:short) descriptors, effects the content-mutated parameter positions (a caller-visible arg-flooring surface). Only meaningful right after a run populated the memo (baseline / recheck), before the bucket rolls. Keys are held live here; the session Marshals them for the snapshot.

Returns:

  • (Hash)

    { [path, "Class#method" | "Class.method"] => { keys:, returns:, effects: } }.



335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/rigor/analysis/runner.rb', line 335

def return_summaries
  memo = Inference::ExpressionTyper.harvest_return_memo
  return {} if memo.empty?

  result = {}
  effects_evaluator = Inference::StatementEvaluator.new(scope: Scope.empty)
  collect_return_summaries(result, @project_discovered_def_nodes, @project_discovered_def_sources,
                           "#", memo, effects_evaluator)
  collect_return_summaries(result, @project_discovered_singleton_def_nodes,
                           @project_discovered_singleton_def_sources, ".", memo, effects_evaluator)
  result
end

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

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.

Parameters:

  • paths (Array[String]) (defaults to: @configuration.paths)

Returns:



191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/rigor/analysis/runner.rb', line 191

def run(paths = @configuration.paths)
  # One per-run file-digest memo spans the whole run, so a path is SHA-256'd at most once across the
  # run-diagnostics dependency descriptor, its `fresh?` validation, the RBS signature tree, and every
  # plugin producer's watched-glob validation (they overlap heavily on the warm path). The nested ADR-85
  # WD3 memo yields one stable `Prism::DefNode` per resolved bundle handle for the run (both are no-ops
  # outside their respective consumers — an empty thread-local table).
  # ADR-87 WD1 — `cache.validation` (or the RIGOR_STRICT_VALIDATION env, which wins) selects the
  # freshness path for this run; the `auto` default resolves strict in CI (#190), stat-first elsewhere.
  Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
    Inference::DefNodeResolver.with_run { run_analysis(paths) }
  end
end

#run_analysis(paths) ⇒ Object



204
205
206
207
208
209
210
211
212
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
# File 'lib/rigor/analysis/runner.rb', line 204

def run_analysis(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)
  @snapshots.reset_for_run
  # Per-run reset of the deferred-discovery memo (see `#ensure_project_discovery`).
  @project_discovery_done = false
  # ADR-84 WD2 — roll the return-memo bucket: a fresh frozen token per run makes every per-file scope
  # of THIS run share one memo bucket while entries from any earlier run in this process (stale after
  # an edit) become unreachable.
  @run_generation = Object.new.freeze

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

  # The recording / subset (ADR-46) modes read the discovery tables outside the analysis assembly, so
  # they force the build eagerly here (matching pre-slice-1 timing); every other mode defers it to the
  # miss path so a warm cache HIT skips the two whole-project parse passes entirely.
  ensure_project_discovery(expansion) if force_eager_discovery?

  diagnostics = compute_run_diagnostics(expansion)

  Result.new(
    diagnostics: @diagnostic_aggregator.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?.



569
570
571
572
573
574
575
576
577
578
579
# File 'lib/rigor/analysis/runner.rb', line 569

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.


556
557
558
559
560
561
562
563
564
# File 'lib/rigor/analysis/runner.rb', line 556

def run_key_descriptor(expansion, rbs_descriptor)
  # ADR-87 WD4 — the key is built through the shared {RunCacheKey} builder the boot-slimming probe also
  # uses, so the miss path (here, passing the loader's `rbs_descriptor.configs`) and the hit path (which
  # reconstructs `rbs.libraries` from config) can never drift out of key agreement.
  RunCacheKey.descriptor(
    configuration: @configuration, files: expansion.fetch(:files),
    explain: @explain, rbs_config_entries: rbs_descriptor.configs
  )
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).

The ADR-46 incremental modes are excluded too, now that they carry a real cache store (ADR-85 WD1): a record_dependencies run MUST perform per-file analysis to capture the dependency graph (a cache-served run records nothing, leaving the next recheck's dependents empty — unsound), and an analyze_only subset run produces intentionally partial diagnostics that share the full run's result key (run_key_descriptor keys on the whole expansion, not the subset), so serving one as the other would manufacture a wrong result. Both instead use the store for the RBS-env + plugin-producer tiers, where the incremental win actually lives.

Returns:

  • (Boolean)


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

def run_result_cacheable?
  !@cache_store.nil? && !@cache_store.read_only? &&
    @buffer.nil? && @prebuilt.nil? && !pool_mode? &&
    !@record_dependencies && @analyze_only.nil?
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.

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

Returns:



251
252
253
254
255
256
# File 'lib/rigor/analysis/runner.rb', line 251

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

#seed_parameter_inference(expansion, environment) ⇒ Object

ADR-67 WD6a — the check-walk parameter-inference pre-pass. Populates @project_param_inferred_types (read by project_scope_seed_tables) with the call-site union of every undeclared parameter, running ONE round (a single hop of call-site → param typing; the protection scan's three-round fixpoint stays a protection-surface luxury until measured). No-op unless parameter_inference: is enabled, so the default run pays exactly nothing here and environment passes through unchanged (preserving the lazy env-build timing). When enabled, it resolves the environment once — the collector types call-site arguments against the same RBS / plugin surface the check uses — and returns it so the sequential dispatch reuses that build. The whole-project file set (not the analyze_only subset) is scanned: the inference is cross-file (a call site in one file types a parameter in another). Fails soft — a collector error must never break a run, so the table stays empty and the run proceeds unseeded.



511
512
513
514
515
516
517
518
519
520
521
522
523
524
# File 'lib/rigor/analysis/runner.rb', line 511

def seed_parameter_inference(expansion, environment)
  return environment unless @configuration.parameter_inference

  files = expansion.fetch(:files)
  environment ||= @pool_coordinator.resolve_sequential_environment(source_files: files)
  @project_param_inferred_types = Inference::ParameterInferenceCollector.collect(
    files: files, environment: environment,
    target_ruby: @configuration.target_ruby, max_rounds: 1, workers: @workers
  )
  environment
rescue StandardError
  @project_param_inferred_types = {}.freeze
  environment
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.



528
529
530
531
532
533
# File 'lib/rigor/analysis/runner.rb', line 528

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.



283
284
285
286
287
288
289
290
291
292
# File 'lib/rigor/analysis/runner.rb', line 283

def symbol_fingerprints
  result = Hash.new { |h, k| h[k] = {} }
  collect_symbol_fingerprints(result, @project_discovered_def_sources, @project_discovered_def_nodes, "#")
  # ADR-46 slice 4 (singleton) — class/singleton-method bodies live in the parallel singleton tables the
  # instance loop never read (recon S5). Fingerprint them under a `"Class.method"` key (the format the
  # `singleton_def_for` dependency edge uses) so a `def self.x` body edit produces a changed pair.
  collect_symbol_fingerprints(result, @project_discovered_singleton_def_sources,
                              @project_discovered_singleton_def_nodes, ".")
  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 — and name the supported floor + where to read the right value, so the fix is obvious without a guess-and-retry loop.



729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
# File 'lib/rigor/analysis/runner.rb', line 729

def validate_target_ruby
  Prism.parse("nil", version: @configuration.target_ruby)
  nil
rescue ArgumentError => e
  floor = self.class.prism_supported_floor
  Diagnostic.new(
    path: ".rigor.yml", line: 1, column: 1,
    message: "target_ruby #{@configuration.target_ruby.inspect} is not supported by this Rigor build " \
             "(Prism accepts #{floor} and newer). Set target_ruby to your project's Ruby version " \
             "(>= #{floor}) — read it from Gemfile.lock's `RUBY VERSION` or .ruby-version. " \
             "(Prism: #{e.message})",
    severity: :error,
    rule: "configuration-error",
    source_family: :builtin
  )
end