Class: Rigor::Analysis::Runner
- Inherits:
-
Object
- Object
- Rigor::Analysis::Runner
- 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 =
".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 misconfiguredtarget_rubyis 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 =
Instance Attribute Summary collapse
-
#analyzed_files ⇒ Array[String]
readonly
Returns the value of attribute analyzed_files.
-
#boundary_cross_reporter ⇒ Object
readonly
Returns the value of attribute boundary_cross_reporter.
-
#buffer ⇒ Object
readonly
ADR-pending editor mode — present when the runner is wired for the
--tmp-file/--instead-ofbuffer-binding shape (docs/design/20260516-editor-mode.md). -
#cache_store ⇒ Object
readonly
Rigor::Cache::Storeitself is not yet sig-covered (the cache namespace is inUNSIGNED_NAMESPACESperspec/rigor/public_api_drift_spec.rb), so reference it asuntypeduntil the full Cache::Store sig lands. -
#dependency_source_index ⇒ Object
readonly
Returns the value of attribute dependency_source_index.
-
#file_dependencies ⇒ Hash[String, untyped]
readonly
ADR-46 — the per-file cross-file read records this run captured (empty unless
record_dependencies: true). -
#plugin_registry ⇒ Object
readonly
Returns the value of attribute plugin_registry.
-
#rbs_extended_reporter ⇒ Object
readonly
Returns the value of attribute rbs_extended_reporter.
-
#seed_bundles ⇒ Object
readonly
Returns the value of attribute seed_bundles.
-
#unresolved_self_calls ⇒ Object
readonly
Returns the value of attribute unresolved_self_calls.
Class Method Summary collapse
-
.prism_supported_floor ⇒ String?
The lowest
target_rubythis Prism build accepts, or nil if none of the ladder parses.
Instance Method Summary collapse
-
#analysis_file_set(paths = @configuration.paths) ⇒ Array[String]
ADR-46 — the project file set that a run over
pathswould 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. - #analyzed_file_entries(expansion) ⇒ Object
- #assemble_run_diagnostics(expansion, environment: nil) ⇒ Object
-
#class_declarations ⇒ Object
ADR-46 slice 3 — per-file set of the qualified class/module names declared in that file.
-
#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. -
#compute_run_diagnostics(expansion) ⇒ Object
ADR-45 — unchanged-project fast path.
-
#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"). -
#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.
-
#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.
-
#evaluate_spec_returns(spec, environment) ⇒ Object
Re-evaluates one spec's def at each of its observed keys.
-
#file_dependents ⇒ Hash[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 }. -
#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
constructor
A new instance of Runner.
-
#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. -
#return_summaries ⇒ Hash
ADR-89 WD2 — the per-method observed-key return summaries the run's ADR-84 return memo just captured.
-
#run(paths = @configuration.paths) ⇒ Result
Walks every Ruby file under
paths, parses it, builds a per-node scope index throughRigor::Inference::ScopeIndexer, and runs theRigor::Analysis::CheckRulescatalogue over it. - #run_analysis(paths) ⇒ Object
-
#run_dependency_descriptor(expansion, rbs_descriptor) ⇒ Object
Files the run actually depended on, collected AFTER it ran: every analyzed file, every RBS
sigfile (rbs_descriptor.files), and every file each plugin read (complete post-run, so reads made mid-analysis are included). -
#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). -
#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).
-
#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.
-
#seed_parameter_inference(expansion, environment) ⇒ Object
ADR-67 WD6a — the check-walk parameter-inference pre-pass.
-
#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.
-
#symbol_fingerprints ⇒ Object
ADR-46 slice 4 — per-symbol body fingerprints, computed from the project pre-pass def index.
-
#validate_target_ruby ⇒ Object
target_rubyflows through to Prism'sversion:option.
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.
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_files ⇒ Array[String] (readonly)
Returns the value of attribute analyzed_files.
56 57 58 |
# File 'lib/rigor/analysis/runner.rb', line 56 def analyzed_files @analyzed_files end |
#boundary_cross_reporter ⇒ Object (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 |
#buffer ⇒ Object (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.
184 185 186 |
# File 'lib/rigor/analysis/runner.rb', line 184 def buffer @buffer end |
#cache_store ⇒ Object (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.
113 114 115 |
# File 'sig/rigor.rbs', line 113 def cache_store @cache_store end |
#dependency_source_index ⇒ Object (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_dependencies ⇒ Hash[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.
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_registry ⇒ Object (readonly)
Returns the value of attribute plugin_registry.
56 57 58 |
# File 'lib/rigor/analysis/runner.rb', line 56 def plugin_registry @plugin_registry end |
#rbs_extended_reporter ⇒ Object (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_bundles ⇒ Object (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_calls ⇒ Object (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_floor ⇒ String?
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).
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.
261 262 263 |
# File 'lib/rigor/analysis/runner.rb', line 261 def analysis_file_set(paths = @configuration.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_declarations ⇒ Object
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.
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 = (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_dependents ⇒ Hash[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.
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 —
@bufferis 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 WITHbuffer:set. - The returned ProjectScan is frozen and shareable; the
underlying
plugin_registryis the same object that ran#prepare, so the per-pluginservices.fact_storeis already populated for subsequent dispatch use.
612 613 614 615 616 617 |
# File 'lib/rigor/analysis/runner.rb', line 612 def prepare_project_scan(paths: @configuration.paths) expansion = (paths) result = @pre_passes.run(expansion: expansion) apply_pre_passes_result(result) @pre_passes.build_project_scan(result) end |
#return_summaries ⇒ Hash
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.
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.
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 = (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.
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).
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_fingerprints ⇒ Object
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_ruby ⇒ Object
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.})", severity: :error, rule: "configuration-error", source_family: :builtin ) end |