Module: Rigor::Protection::DiscoverySeed

Defined in:
lib/rigor/protection/discovery_seed.rb

Overview

Issue #260 — the cross-file knowledge Tier 2 hands to BOTH halves of a mutation measurement.

Site selection and the kill oracle used to disagree by construction: the site filter judged a receiver against a scope seeded with discovered_classes, so Account.find (with Account declared in a sibling file) entered the denominator — while the oracle re-analysed each mutant through Runner.new(prebuilt:)#run_source, whose cross-file discovery tables stay frozen-empty, so the same receiver read Dynamic and NO mutation at that site could ever produce a diagnostic. Every admitted cross-file site was therefore a guaranteed survivor: on Rigor's own lib, +2,183 sites bought +2,187 survivors and moved killed by −4.

The answer (issue #260's recorded decision) is one table set, built once, threaded to both sides: the Mutator base scope and DiagnosticOracle's runner seam. This module builds that table set — the FULL discovery bundle (discovered_def_nodes, discovered_methods, superclasses, includes, …), not just the class-identity slice site selection needed, because resolving find on singleton(Account) is what makes the site killable.

The shape is a plain Hash of Scope::DiscoveryIndex slot names, which is exactly what both consumers take: scope.discovery.with(**tables) on one side, Runner#project_scope_seed_tables on the other.

Built ONCE on the parent, before CLI::MutationForkScan forks, so children copy-on-write inherit it. Nothing here crosses the marshal boundary (only per-file results do) — which matters, because the def-node tables hold live Prism::Nodes.

Class Method Summary collapse

Class Method Details

.build(paths:, environment:, target_ruby:, workers: 0) ⇒ Hash{Symbol => Object}

Returns frozen seed tables; empty when the paths yield nothing.

Parameters:

  • paths (Array<String>)

    the measured file set; the seed spans these files only, exactly as Tier 1's seed does. A class declared outside them stays unknown.

  • environment (Rigor::Environment)

    the plugin-aware environment, built once by the caller.

  • target_ruby (String)

    Prism parse version for the parameter-inference pre-pass.

  • workers (Integer) (defaults to: 0)

    worker count for that pre-pass (0 keeps it sequential).

Returns:

  • (Hash{Symbol => Object})

    frozen seed tables; empty when the paths yield nothing.



39
40
41
42
43
44
45
46
# File 'lib/rigor/protection/discovery_seed.rb', line 39

def build(paths:, environment:, target_ruby:, workers: 0)
  tables = discovery_tables(paths)
  params = Inference::ParameterInferenceCollector.collect(
    files: paths, environment: environment, target_ruby: target_ruby, workers: workers
  )
  tables[:param_inferred_types] = params unless params.empty?
  tables.freeze
end

.bundles(paths:) ⇒ Hash{String => Hash}

Issue #254 — the per-file discovery bundles (ADR-85 WD2) the closure kill oracle re-folds once per mutant. A bundle is one file's isolated contribution to the tables above, plus the content digest that decides whether it is still valid; folding the whole set reconstructs exactly what #discovery_tables builds. Built ONCE on the parent (before CLI::MutationForkScan forks, so children copy-on-write inherit it), and cheap: ≈0.36s over Rigor's own 349-file lib.

Parameters:

  • paths (Array<String>)

    the measured file set, in canonical (caller) order.

Returns:

  • (Hash{String => Hash})

    per-path bundles, the input #tables_for_buffer re-folds.



56
57
58
# File 'lib/rigor/protection/discovery_seed.rb', line 56

def bundles(paths:)
  Inference::ScopeIndexer.discovered_project_index_incremental(paths, seed_bundles: {}).fetch(:bundles)
end

.discovery_tables(paths) ⇒ Object

The whole-project discovery tables, from the single-walk combined pass (one parse per file, both collectors driven over the same tree) that Analysis::Runner::ProjectPrePasses#discover uses. Empty tables are dropped so an empty seed stays {} and every consumer's "seed nothing" branch keeps working.

discovered_class_sources is deliberately NOT carried: the runner itself seeds it only under dependency recording (it is read by the ancestry accessors solely to record cross-file edges), and Tier 2 never records.



90
91
92
# File 'lib/rigor/protection/discovery_seed.rb', line 90

def discovery_tables(paths)
  seed_tables(Inference::ScopeIndexer.discovered_project_index_for_paths(paths))
end

.seed_key(slot) ⇒ Object

The def-index slot names match the Scope::DiscoveryIndex ones under a discovered_ prefix, except the two member-layout tables, which carry the same name on both sides.



111
112
113
# File 'lib/rigor/protection/discovery_seed.rb', line 111

def seed_key(slot)
  slot.to_s.end_with?("member_layouts") ? slot : :"discovered_#{slot}"
end

.seed_tables(index) ⇒ Object

Maps a { classes:, def_index: } discovery index onto the Scope::DiscoveryIndex slot names both consumers take. Shared by the whole-walk (#discovery_tables) and bundle-fold (#tables_for_buffer) producers so a mutant's seed and the parent's seed can never disagree about shape.



97
98
99
100
101
102
103
104
105
106
107
# File 'lib/rigor/protection/discovery_seed.rb', line 97

def seed_tables(index)
  def_index = index.fetch(:def_index)
  tables = { discovered_classes: index.fetch(:classes) }
  %i[
    def_nodes singleton_def_nodes def_sources singleton_def_sources superclasses includes
    method_visibilities methods data_member_layouts struct_member_layouts
  ].each do |slot|
    tables[seed_key(slot)] = def_index.fetch(slot)
  end
  tables.reject { |_, table| table.nil? || table.empty? }
end

.tables_for_buffer(paths:, bundles:, buffer:) ⇒ Hash{Symbol => Object}

Issue #254 — the seed tables for ONE mutant: every measured file's bundle folded back unchanged EXCEPT the file buffer binds, which is re-walked from the buffer's bytes.

This is where the substitution reaches change detection rather than only the analysis. The incremental pass digests each path through the binding, so the mutated file's digest is the MUTANT's and never matches its cached bundle — it is re-walked, and every dependent then reads the mutated def bodies out of the seed instead of the ones still on disk. Without it a closure re-analysis would resolve the clean method, produce no new diagnostic anywhere but the mutated file itself, and report a plausible-looking effectiveness number that measured nothing new.

≈15ms over 349 files (one re-walk plus a whole-set fold), against ≈210ms for one mutant's analysis.

Parameters:

  • paths (Array<String>)

    the measured file set, in the same order #bundles was built from.

  • bundles (Hash{String => Hash})

    that bundle set.

  • buffer (Rigor::Analysis::BufferBinding)

    the mutant binding (logical path → mutant bytes).

Returns:

  • (Hash{Symbol => Object})

    frozen seed tables.



76
77
78
79
80
81
# File 'lib/rigor/protection/discovery_seed.rb', line 76

def tables_for_buffer(paths:, bundles:, buffer:)
  index = Inference::ScopeIndexer.discovered_project_index_incremental(
    paths, seed_bundles: bundles, buffer: buffer
  )
  seed_tables(index).freeze
end