Class: Rigor::Analysis::Runner

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

Overview

rubocop:disable Metrics/ClassLength

Constant Summary collapse

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

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

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

Returns a new instance of Runner.

Parameters:

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

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

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

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

  • workers (Integer) (defaults to: 0)

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

  • collect_stats (Boolean) (defaults to: true)

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

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

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

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

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



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/rigor/analysis/runner.rb', line 82

def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists
               cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
               plugin_requirer: nil, workers: 0, collect_stats: true,
               buffer: nil, prebuilt: nil, environment: 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
  @plugin_registry = Plugin::Registry::EMPTY
  @dependency_source_index = DependencySourceInference::Index::EMPTY
  @rbs_extended_reporter = RbsExtended::Reporter.new
  @boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.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.
  @class_decl_paths_snapshot = {}.freeze
  @signature_paths_snapshot = [].freeze
  @cached_plugin_prepare_diagnostics = [].freeze
  @project_discovered_classes = {}.freeze
end

Instance Attribute Details

#boundary_cross_reporterObject (readonly)

Returns the value of attribute boundary_cross_reporter.



32
33
34
# File 'lib/rigor/analysis/runner.rb', line 32

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.



113
114
115
# File 'lib/rigor/analysis/runner.rb', line 113

def buffer
  @buffer
end

#cache_storeObject (readonly)

Returns the value of attribute cache_store.



32
33
34
# File 'lib/rigor/analysis/runner.rb', line 32

def cache_store
  @cache_store
end

#dependency_source_indexObject (readonly)

Returns the value of attribute dependency_source_index.



32
33
34
# File 'lib/rigor/analysis/runner.rb', line 32

def dependency_source_index
  @dependency_source_index
end

#plugin_registryObject (readonly)

Returns the value of attribute plugin_registry.



32
33
34
# File 'lib/rigor/analysis/runner.rb', line 32

def plugin_registry
  @plugin_registry
end

#rbs_extended_reporterObject (readonly)

Returns the value of attribute rbs_extended_reporter.



32
33
34
# File 'lib/rigor/analysis/runner.rb', line 32

def rbs_extended_reporter
  @rbs_extended_reporter
end

Instance Method Details

#analyze_files(files) ⇒ Object

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

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



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/rigor/analysis/runner.rb', line 277

def analyze_files(files)
  return [] if files.empty?

  if pool_mode?
    analyze_files_in_pool(files)
  else
    environment = resolve_sequential_environment
    result = files.flat_map { |path| analyze_file(path, environment) }
    if @collect_stats
      loader = environment.rbs_loader
      @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
      @signature_paths_snapshot = loader&.signature_paths || [].freeze
    end
    result
  end
end

#diagnostic_from_hash(hash) ⇒ Object



384
385
386
387
388
389
390
# File 'lib/rigor/analysis/runner.rb', line 384

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

#pre_eval_diagnosticsObject

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

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



367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/rigor/analysis/runner.rb', line 367

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

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

#pre_file_diagnostics(expansion) ⇒ Object

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

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



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/rigor/analysis/runner.rb', line 328

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

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

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

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

Notes for callers:

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

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



175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/rigor/analysis/runner.rb', line 175

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

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

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



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
# File 'lib/rigor/analysis/runner.rb', line 123

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

  wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)

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

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

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

  diagnostics = pre_file_diagnostics(expansion)
  diagnostics += analyze_files(target_files(expansion))
  diagnostics += rbs_extended_reporter_diagnostics
  diagnostics += boundary_cross_diagnostics

  Result.new(
    diagnostics: apply_severity_profile(diagnostics),
    stats: @collect_stats ? build_run_stats(wall_started_at: wall_started_at, expansion: expansion) : nil
  )
end

#shared_fact_storeObject

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



350
351
352
353
354
# File 'lib/rigor/analysis/runner.rb', line 350

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

  @plugin_registry.plugins.first&.services&.fact_store
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.



398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/rigor/analysis/runner.rb', line 398

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