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.



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
108
109
110
111
# File 'lib/rigor/analysis/runner.rb', line 83

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
  @project_discovered_def_nodes = {}.freeze
  @project_discovered_superclasses = {}.freeze
  @project_discovered_includes = {}.freeze
end

Instance Attribute Details

#boundary_cross_reporterObject (readonly)

Returns the value of attribute boundary_cross_reporter.



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

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.



117
118
119
# File 'lib/rigor/analysis/runner.rb', line 117

def buffer
  @buffer
end

#cache_storeObject (readonly)

Returns the value of attribute cache_store.



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

def cache_store
  @cache_store
end

#dependency_source_indexObject (readonly)

Returns the value of attribute dependency_source_index.



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

def dependency_source_index
  @dependency_source_index
end

#plugin_registryObject (readonly)

Returns the value of attribute plugin_registry.



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

def plugin_registry
  @plugin_registry
end

#rbs_extended_reporterObject (readonly)

Returns the value of attribute rbs_extended_reporter.



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

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.



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/rigor/analysis/runner.rb', line 291

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

  if pool_mode?
    dispatch_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



398
399
400
401
402
403
404
# File 'lib/rigor/analysis/runner.rb', line 398

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.



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

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



342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/rigor/analysis/runner.rb', line 342

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.



179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/rigor/analysis/runner.rb', line 179

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.



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

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.



364
365
366
367
368
# File 'lib/rigor/analysis/runner.rb', line 364

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.



412
413
414
415
416
417
418
419
420
421
422
423
# File 'lib/rigor/analysis/runner.rb', line 412

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