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,
lib/rigor/analysis/runner/buffer_pool_dispatcher.rb,
sig/rigor.rbs

Overview

rubocop:disable Metrics/ClassLength

Defined Under Namespace

Classes: BufferPoolDispatcher, 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, param_inferred_types: nil, discovery_seed: 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 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.

  • discovery_seed (Hash, nil) (defaults to: nil)

    issue #260 — opt-in cross-file discovery tables, keyed by Scope::DiscoveryIndex slot name, seeded onto every per-file scope through project_scope_seed_tables. The ONE deliberate exception to "a prebuilt: runner carries no discovery tables": Protection::DiagnosticOracle threads the table set Tier 2's site filter already judges anchors against, so a site admitted because a sibling-file class resolved is also a site the oracle can kill at. nil (the default) leaves the prebuilt/LSP contract byte-identical.

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


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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/rigor/analysis/runner.rb', line 105

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, param_inferred_types: nil,
               discovery_seed: 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)
  # 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
  # ADR-67 WD6c lift — a precomputed inferred-param table. When the incremental session already ran the
  # collector (it must, to diff the table against its snapshot BEFORE deciding the re-analyse closure),
  # it hands the result here so `seed_parameter_inference` seeds without a second whole-project collect
  # — and so the table the diff was decided on and the table this run seeds from are the SAME object,
  # not merely an equal recomputation. nil (the default) keeps the runner self-sufficient.
  @param_inferred_types_override = param_inferred_types
  # Issue #260 — the opt-in cross-file discovery seed (see the `discovery_seed:` doc above). Frozen and
  # never mutated; `project_scope_seed_tables` starts from a copy of it and lets any table this run
  # actually computed win.
  @discovery_seed = discovery_seed&.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])


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

def analyzed_files
  @analyzed_files
end

#boundary_cross_reporterObject (readonly)

Returns the value of attribute boundary_cross_reporter.



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

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)


202
203
204
# File 'lib/rigor/analysis/runner.rb', line 202

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.



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

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])


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

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)


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

def plugin_registry
  @plugin_registry
end

#rbs_extended_reporterObject (readonly)

Returns the value of attribute rbs_extended_reporter.



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

def rbs_extended_reporter
  @rbs_extended_reporter
end

#seed_bundlesObject (readonly)

Returns the value of attribute seed_bundles.



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

def seed_bundles
  @seed_bundles
end

#unresolved_self_callsObject (readonly)

Returns the value of attribute unresolved_self_calls.



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

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



768
769
770
771
772
773
774
775
# File 'lib/rigor/analysis/runner.rb', line 768

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])


279
280
281
# File 'lib/rigor/analysis/runner.rb', line 279

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

#analyzed_file_entries(expansion) ⇒ Object



630
631
632
633
634
635
636
637
# File 'lib/rigor/analysis/runner.rb', line 630

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



517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
# File 'lib/rigor/analysis/runner.rb', line 517

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



337
338
339
340
341
342
343
# File 'lib/rigor/analysis/runner.rb', line 337

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_param_inference_table(files) ⇒ Object

ADR-67 WD6c lift — computes the whole-project inferred-param table without running an analysis. The incremental session calls this BEFORE deciding its re-analyse closure: the table's diff against the snapshot's stored table is what invalidates a callee whose seeds moved because a caller file changed. Same collector invocation as #seed_parameter_inference (one round, same workers), so the session-computed table and an in-run collect are byte-identical by construction. Fails soft to the empty table — which the caller's diff then treats as "every stored entry removed", the conservative direction (those callees re-check).



359
360
361
362
363
364
365
366
367
368
369
# File 'lib/rigor/analysis/runner.rb', line 359

def collect_param_inference_table(files)
  return {}.freeze unless @configuration.parameter_inference

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



397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/rigor/analysis/runner.rb', line 397

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



484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# File 'lib/rigor/analysis/runner.rb', line 484

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").



422
423
424
425
426
# File 'lib/rigor/analysis/runner.rb', line 422

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, …] }.



439
440
441
442
443
444
445
# File 'lib/rigor/analysis/runner.rb', line 439

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.



449
450
451
452
453
454
455
456
457
458
459
# File 'lib/rigor/analysis/runner.rb', line 449

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



465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/rigor/analysis/runner.rb', line 465

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])


290
291
292
# File 'lib/rigor/analysis/runner.rb', line 290

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

#param_inferred_typesObject

ADR-67 WD6c lift — the inferred-param table this run seeded from (frozen; empty when parameter_inference: is off or the pre-pass failed soft). The incremental session reads it back after a baseline so the snapshot records the seeds the cached diagnostics were computed under.



348
349
350
# File 'lib/rigor/analysis/runner.rb', line 348

def param_inferred_types
  @project_param_inferred_types
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)


661
662
663
664
665
666
# File 'lib/rigor/analysis/runner.rb', line 661

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: } }.



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

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:



209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/rigor/analysis/runner.rb', line 209

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



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
250
251
252
253
254
255
256
257
# File 'lib/rigor/analysis/runner.rb', line 222

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



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

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.


605
606
607
608
609
610
611
612
613
# File 'lib/rigor/analysis/runner.rb', line 605

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)


595
596
597
598
599
# File 'lib/rigor/analysis/runner.rb', line 595

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:



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

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.



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

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

  if @param_inferred_types_override
    @project_param_inferred_types = @param_inferred_types_override
    return environment
  end

  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.



577
578
579
580
581
582
# File 'lib/rigor/analysis/runner.rb', line 577

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.



301
302
303
304
305
306
307
308
309
310
# File 'lib/rigor/analysis/runner.rb', line 301

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.



782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
# File 'lib/rigor/analysis/runner.rb', line 782

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