Class: Henitai::Runner
- Inherits:
-
Object
- Object
- Henitai::Runner
- Defined in:
- lib/henitai/runner.rb,
sig/henitai.rbs
Overview
Orchestrates the full mutation testing pipeline.
Pipeline phases (Phase-Gate model):
Gate 1 — Subject selection
Resolve source files from includes, apply --since filter (incremental),
build Subject list from AST.
Gate 2 — Mutant generation
Apply operators to each Subject's AST. Filter arid (non-productive)
nodes via ignore_patterns. Produces the initial mutant list.
Gate 3 — Static filtering
Remove ignored mutants (pattern matches), compile-time errors.
Apply per-test coverage data: mark :no_coverage for uncovered mutants.
Gate 4 — Mutant execution
Run surviving mutants in isolated child processes (fork isolation).
Each child process loads the test suite with the mutated method
injected via Module#define_method. Collect kill/survive/timeout results.
Gate 5 — Reporting
Write results to configured reporters (terminal, html, json, dashboard).
Instance Attribute Summary collapse
-
#config ⇒ Configuration
readonly
Returns the value of attribute config.
-
#deps ⇒ RunnerDependencies
readonly
Returns the value of attribute deps.
-
#result ⇒ Object
readonly
Returns the value of attribute result.
Instance Method Summary collapse
-
#apply_incremental_filter(mutants) ⇒ Array[Mutant]
Opt-in verdict reuse (
--incremental): still-valid Killed and Survived verdicts from the history store are marked with their stored status + from_cache before execution. - #bootstrap_coverage(source_files, test_files = nil) ⇒ void
- #bootstrap_mutants(source_files) ⇒ Thread
- #build_result(mutants, started_at, finished_at) ⇒ Result
- #build_result_object(mutants, started_at, finished_at) ⇒ Result
- #coverage_bootstrapper ⇒ CoverageBootstrapper
-
#dry_run_result(mutants, started_at, finished_at) ⇒ Result
Dry run stops before Gate 4: prints the post-filter listing and returns a Result without executing mutants, persisting history or running the configured reporters.
- #execute_mutants(mutants) ⇒ Object
- #execution_engine ⇒ Object
- #filter_mutants(mutants) ⇒ Object
-
#full_run? ⇒ Boolean
Mutation-scope full run, controlling Result#authoritative? — distinct from the per-test-coverage plan's test-suite-scope "full run".
- #generate_mutants(subjects) ⇒ Object
- #git_diff_analyzer ⇒ Object
- #history_store ⇒ Object
- #history_store_path ⇒ String
-
#initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil, mode: {}, deps: nil) ⇒ Runner
constructor
depsis assigned in the body, not defaulted in the signature: the default needs @config, andconfig:itself defaults to a load. - #integration ⇒ Object
- #mutant_generator ⇒ Object
- #mutants_for(subjects, source_files) ⇒ Array[Mutant]
- #operators ⇒ Object
-
#optional_config(name) ⇒ Object
Specs pass bare config doubles that expose only what the example needs, so scoring inputs are read defensively rather than assumed present.
- #per_test_coverage ⇒ PerTestCoverage
- #persist_history(result, recorded_at) ⇒ void
-
#pipeline_mutants ⇒ Array[Mutant]
Gates 0–3 only: coverage bootstrap, subject resolution, generation and static/skip/arid/stillborn filtering — everything short of execution.
- #progress_reporter ⇒ Object?
- #report(result) ⇒ void
- #resolve_subjects(source_files = self.source_files) ⇒ Object
- #result_coverage_criteria ⇒ Hash[Symbol, bool]?
- #result_thresholds ⇒ Hash[Symbol, Integer]?
-
#run ⇒ Result
Entry point — runs the full pipeline and returns a Result.
- #safe_head_sha ⇒ String?
- #source_file_selection ⇒ SourceFileSelection
- #source_files ⇒ Array[String]
- #source_provider ⇒ ^(String) -> String
- #static_filter ⇒ Object
- #subject_resolver ⇒ Object
- #subject_selection ⇒ SubjectSelection
- #survivor_rerun? ⇒ Boolean
- #survivor_strategy ⇒ SurvivorRerunStrategy
- #with_reports_dir { ... } ⇒ Object
Constructor Details
#initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil, mode: {}, deps: nil) ⇒ Runner
deps is assigned in the body, not defaulted in the signature: the
default needs @config, and config: itself defaults to a load.
rubocop:disable Metrics/ParameterLists -- these are the CLI's own flags plus the dependency seam; a params object would only move the list.
38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/henitai/runner.rb', line 38 def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil, mode: {}, deps: nil) # rubocop:enable Metrics/ParameterLists @config = config @deps = deps || RunnerDependencies.new(config: @config) @subjects = subjects @since = since @survivors_from = survivors_from @dry_run = mode.fetch(:dry_run, false) @incremental = mode.fetch(:incremental, false) end |
Instance Attribute Details
#config ⇒ Configuration (readonly)
Returns the value of attribute config.
29 30 31 |
# File 'lib/henitai/runner.rb', line 29 def config @config end |
#deps ⇒ RunnerDependencies (readonly)
Returns the value of attribute deps.
196 197 198 |
# File 'lib/henitai/runner.rb', line 196 def deps @deps end |
#result ⇒ Object (readonly)
Returns the value of attribute result.
29 30 31 |
# File 'lib/henitai/runner.rb', line 29 def result @result end |
Instance Method Details
#apply_incremental_filter(mutants) ⇒ Array[Mutant]
Opt-in verdict reuse (--incremental): still-valid Killed and Survived
verdicts from the history store are marked with their stored status +
from_cache before execution. The filter is only ever constructed when
the flag is set; it runs after the coverage bootstrap join in
mutants_for, so the live per-test map it reads is never mid-write.
130 131 132 133 134 135 136 |
# File 'lib/henitai/runner.rb', line 130 def apply_incremental_filter(mutants) return mutants unless @incremental IncrementalFilter.new(history_store:, per_test_coverage:, dependency_fingerprint: VerdictFingerprint.dependency_fingerprint) .apply(mutants) end |
#bootstrap_coverage(source_files, test_files = nil) ⇒ void
This method returns an undefined value.
192 193 194 |
# File 'lib/henitai/runner.rb', line 192 def bootstrap_coverage(source_files, test_files = nil) coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:) end |
#bootstrap_mutants(source_files) ⇒ Thread
138 |
# File 'lib/henitai/runner.rb', line 138 def bootstrap_mutants(source_files) = Thread.new { bootstrap_coverage(source_files) } |
#build_result(mutants, started_at, finished_at) ⇒ Result
161 162 163 164 165 166 |
# File 'lib/henitai/runner.rb', line 161 def build_result(mutants, started_at, finished_at) @result = build_result_object(mutants, started_at, finished_at) persist_history(@result, finished_at) report(@result) @result end |
#build_result_object(mutants, started_at, finished_at) ⇒ Result
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/henitai/runner.rb', line 168 def build_result_object(mutants, started_at, finished_at) Result.new( mutants:, started_at:, finished_at:, thresholds: result_thresholds, coverage_criteria: result_coverage_criteria, partial_rerun: survivor_rerun?, survivor_stats: survivor_strategy.survivor_stats, git_sha: safe_head_sha, source_provider: source_provider, authoritative: full_run?, since: @since ) end |
#coverage_bootstrapper ⇒ CoverageBootstrapper
203 |
# File 'lib/henitai/runner.rb', line 203 def coverage_bootstrapper = deps.coverage_bootstrapper |
#dry_run_result(mutants, started_at, finished_at) ⇒ Result
Dry run stops before Gate 4: prints the post-filter listing and returns a Result without executing mutants, persisting history or running the configured reporters. Gate 0 and lock coordination may still write under reports_dir.
91 92 93 94 95 |
# File 'lib/henitai/runner.rb', line 91 def dry_run_result(mutants, started_at, finished_at) @result = build_result_object(mutants, started_at, finished_at) Reporter::DryRun.new(config:).report(@result) @result end |
#execute_mutants(mutants) ⇒ Object
140 141 142 143 144 145 146 147 |
# File 'lib/henitai/runner.rb', line 140 def execute_mutants(mutants) execution_engine.run( mutants, integration, config, progress_reporter: progress_reporter ) end |
#execution_engine ⇒ Object
202 |
# File 'lib/henitai/runner.rb', line 202 def execution_engine = deps.execution_engine |
#filter_mutants(mutants) ⇒ Object
111 112 113 |
# File 'lib/henitai/runner.rb', line 111 def filter_mutants(mutants) static_filter.apply(mutants, config) end |
#full_run? ⇒ Boolean
Mutation-scope full run, controlling Result#authoritative? — distinct from the per-test-coverage plan's test-suite-scope "full run".
235 236 237 |
# File 'lib/henitai/runner.rb', line 235 def full_run? Array(@subjects).empty? && @since.nil? && !survivor_rerun? end |
#generate_mutants(subjects) ⇒ Object
107 108 109 |
# File 'lib/henitai/runner.rb', line 107 def generate_mutants(subjects) mutant_generator.generate(subjects, operators, config:) end |
#git_diff_analyzer ⇒ Object
199 |
# File 'lib/henitai/runner.rb', line 199 def git_diff_analyzer = deps.git_diff_analyzer |
#history_store ⇒ Object
206 |
# File 'lib/henitai/runner.rb', line 206 def history_store = deps.history_store |
#history_store_path ⇒ String
1147 |
# File 'sig/henitai.rbs', line 1147
def history_store_path: () -> String
|
#integration ⇒ Object
204 |
# File 'lib/henitai/runner.rb', line 204 def integration = deps.integration |
#mutant_generator ⇒ Object
200 |
# File 'lib/henitai/runner.rb', line 200 def mutant_generator = deps.mutant_generator |
#mutants_for(subjects, source_files) ⇒ Array[Mutant]
115 116 117 118 119 120 121 122 123 |
# File 'lib/henitai/runner.rb', line 115 def mutants_for(subjects, source_files) bootstrap_thread = bootstrap_mutants(source_files) mutants = generate_mutants(subjects) bootstrap_thread.value filtered = apply_incremental_filter(filter_mutants(mutants)) return filtered unless survivor_rerun? survivor_strategy.apply_selection(filtered) end |
#operators ⇒ Object
205 |
# File 'lib/henitai/runner.rb', line 205 def operators = deps.operators |
#optional_config(name) ⇒ Object
Specs pass bare config doubles that expose only what the example needs, so scoring inputs are read defensively rather than assumed present.
229 |
# File 'lib/henitai/runner.rb', line 229 def optional_config(name) = config.respond_to?(name) ? config.public_send(name) : nil |
#per_test_coverage ⇒ PerTestCoverage
207 |
# File 'lib/henitai/runner.rb', line 207 def per_test_coverage = deps.per_test_coverage |
#persist_history(result, recorded_at) ⇒ void
This method returns an undefined value.
153 154 155 156 157 158 159 |
# File 'lib/henitai/runner.rb', line 153 def persist_history(result, recorded_at) history_store.record( result, version: Henitai::VERSION, recorded_at: ) end |
#pipeline_mutants ⇒ Array[Mutant]
Gates 0–3 only: coverage bootstrap, subject resolution, generation and static/skip/arid/stillborn filtering — everything short of execution.
77 78 79 80 81 82 83 84 85 |
# File 'lib/henitai/runner.rb', line 77 def pipeline_mutants if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run) return fast_mutants end source_files = self.source_files subjects = resolve_subjects(source_files) mutants_for(subjects, source_files) end |
#progress_reporter ⇒ Object?
210 |
# File 'lib/henitai/runner.rb', line 210 def progress_reporter = deps.progress_reporter(full_run: full_run?) |
#report(result) ⇒ void
This method returns an undefined value.
149 150 151 |
# File 'lib/henitai/runner.rb', line 149 def report(result) Reporter.run_all(names: config.reporters, result:, config:, history_store:) end |
#resolve_subjects(source_files = self.source_files) ⇒ Object
97 98 99 |
# File 'lib/henitai/runner.rb', line 97 def resolve_subjects(source_files = self.source_files) subject_selection.resolve(source_files) end |
#result_coverage_criteria ⇒ Hash[Symbol, bool]?
225 |
# File 'lib/henitai/runner.rb', line 225 def result_coverage_criteria = optional_config(:coverage_criteria) |
#result_thresholds ⇒ Hash[Symbol, Integer]?
223 |
# File 'lib/henitai/runner.rb', line 223 def result_thresholds = optional_config(:thresholds) |
#run ⇒ Result
Entry point — runs the full pipeline and returns a Result.
Fast path (recipe rerun): when --survivors-from is given and an
activation-recipes.json file exists beside the report with entries for
all survivor IDs, stub Mutants are built from the recipes and the full
source-parse / mutant-generation pipeline is skipped entirely.
Normal path: Coverage bootstrap (Gate 0) runs in a background thread so that Gate 1 (subject resolution) and Gate 2 (mutant generation) proceed concurrently. The thread is joined before Gate 3 (static filtering).
62 63 64 65 66 67 68 69 70 71 |
# File 'lib/henitai/runner.rb', line 62 def run ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do started_at = Time.now mutants = pipeline_mutants return dry_run_result(mutants, started_at, Time.now) if @dry_run build_result(execute_mutants(mutants), started_at, Time.now) end end |
#safe_head_sha ⇒ String?
184 185 186 187 188 189 190 |
# File 'lib/henitai/runner.rb', line 184 def safe_head_sha git_diff_analyzer.head_sha rescue StandardError # `head_sha` rescues Errno::ENOENT. This extra rescue is defensive for # unexpected Open3/git runtime errors; conservative fallback is `nil`. nil end |
#source_file_selection ⇒ SourceFileSelection
216 217 218 219 220 221 |
# File 'lib/henitai/runner.rb', line 216 def source_file_selection @source_file_selection ||= SourceFileSelection.new( config: config, since: @since, git_diff_analyzer: git_diff_analyzer, per_test_coverage: per_test_coverage ) end |
#source_files ⇒ Array[String]
212 213 214 |
# File 'lib/henitai/runner.rb', line 212 def source_files @source_files ||= source_file_selection.call end |
#source_provider ⇒ ^(String) -> String
208 |
# File 'lib/henitai/runner.rb', line 208 def source_provider = deps.source_provider |
#static_filter ⇒ Object
201 |
# File 'lib/henitai/runner.rb', line 201 def static_filter = deps.static_filter |
#subject_resolver ⇒ Object
198 |
# File 'lib/henitai/runner.rb', line 198 def subject_resolver = deps.subject_resolver |
#subject_selection ⇒ SubjectSelection
101 102 103 104 105 |
# File 'lib/henitai/runner.rb', line 101 def subject_selection @subject_selection ||= SubjectSelection.new( subject_resolver: subject_resolver, patterns: @subjects ) end |
#survivor_rerun? ⇒ Boolean
231 |
# File 'lib/henitai/runner.rb', line 231 def survivor_rerun? = !@survivors_from.nil? |
#survivor_strategy ⇒ SurvivorRerunStrategy
239 240 241 242 243 244 245 |
# File 'lib/henitai/runner.rb', line 239 def survivor_strategy @survivor_strategy ||= SurvivorRerunStrategy.new( survivors_from: @survivors_from, config:, git_diff_analyzer: ) end |
#with_reports_dir { ... } ⇒ Object
1164 |
# File 'sig/henitai.rbs', line 1164
def with_reports_dir: () { () -> untyped } -> untyped
|