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.
-
#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.
- #excluded_source_files ⇒ Array[String]
- #execute_mutants(mutants) ⇒ Object
- #execution_engine ⇒ Object
- #filter_changed(files) ⇒ Array[String]
- #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
- #included_source_files ⇒ Array[String]
-
#initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil, mode: {}) ⇒ Runner
constructor
A new instance of Runner.
- #integration ⇒ Object
- #mutant_generator ⇒ Object
- #mutants_for(subjects, source_files) ⇒ Array[Mutant]
- #normalize_path(path) ⇒ String
- #operators ⇒ Object
- #pattern_subjects ⇒ Array[Subject]
-
#per_test_coverage ⇒ PerTestCoverage
One shared live view of the per-test coverage map: the incremental filter proves survivor reuse against it and the history store records the same intersection set — one implementation, one snapshot.
- #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?
Fans progress out to the terminal reporter (when enabled) and the checkpoint writer (when enabled and a file report is configured), so a long run persists partial results incrementally.
-
#reject_excluded(files) ⇒ Array[String]
Drops files matched by any
excludes:glob (e.g. standalone entry points that cannot be mutation-tested in-process). - #report(result) ⇒ void
- #resolve_subjects(source_files = self.source_files) ⇒ Object
- #result_thresholds ⇒ Hash[Symbol, Integer]?
-
#run ⇒ Result
Entry point — runs the full pipeline and returns a Result.
- #safe_head_sha ⇒ String?
- #source_files ⇒ Array[String]
-
#source_provider ⇒ ^(String) -> String
Reads each source file once and caches it, so Result consumes source content while performing no disk IO of its own.
- #static_filter ⇒ Object
- #subject_resolver ⇒ Object
- #survivor_rerun? ⇒ Boolean
- #survivor_strategy ⇒ SurvivorRerunStrategy
- #unique_subjects(subjects) ⇒ Array[Subject]
- #with_reports_dir { ... } ⇒ Object
Constructor Details
#initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil, mode: {}) ⇒ Runner
Returns a new instance of Runner.
33 34 35 36 37 38 39 40 41 |
# File 'lib/henitai/runner.rb', line 33 def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil, mode: {}) @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 |
#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.
123 124 125 126 127 128 129 |
# File 'lib/henitai/runner.rb', line 123 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.
197 198 199 |
# File 'lib/henitai/runner.rb', line 197 def bootstrap_coverage(source_files, test_files = nil) coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:) end |
#bootstrap_mutants(source_files) ⇒ Thread
131 |
# File 'lib/henitai/runner.rb', line 131 def bootstrap_mutants(source_files) = Thread.new { bootstrap_coverage(source_files) } |
#build_result(mutants, started_at, finished_at) ⇒ Result
154 155 156 157 158 159 |
# File 'lib/henitai/runner.rb', line 154 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
161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/henitai/runner.rb', line 161 def build_result_object(mutants, started_at, finished_at) Result.new( mutants:, started_at:, finished_at:, thresholds: result_thresholds, partial_rerun: survivor_rerun?, survivor_stats: survivor_strategy.survivor_stats, git_sha: safe_head_sha, source_provider: source_provider, authoritative: full_run? ) end |
#coverage_bootstrapper ⇒ CoverageBootstrapper
211 |
# File 'lib/henitai/runner.rb', line 211 def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new |
#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.
84 85 86 87 88 |
# File 'lib/henitai/runner.rb', line 84 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 |
#excluded_source_files ⇒ Array[String]
261 262 263 264 265 |
# File 'lib/henitai/runner.rb', line 261 def excluded_source_files Array(config.excludes) .flat_map { |pattern| Dir.glob(pattern) } .map { |path| normalize_path(path) } end |
#execute_mutants(mutants) ⇒ Object
133 134 135 136 137 138 139 140 |
# File 'lib/henitai/runner.rb', line 133 def execute_mutants(mutants) execution_engine.run( mutants, integration, config, progress_reporter: progress_reporter ) end |
#execution_engine ⇒ Object
209 |
# File 'lib/henitai/runner.rb', line 209 def execution_engine = @execution_engine ||= ExecutionEngine.new |
#filter_changed(files) ⇒ Array[String]
267 268 269 270 271 272 273 274 |
# File 'lib/henitai/runner.rb', line 267 def filter_changed(files) return files unless @since changed_file_set = git_diff_analyzer .changed_files(from: @since, to: "HEAD") .map { |path| normalize_path(path) } files.select { |path| changed_file_set.include?(normalize_path(path)) } end |
#filter_mutants(mutants) ⇒ Object
104 105 106 |
# File 'lib/henitai/runner.rb', line 104 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".
300 301 302 |
# File 'lib/henitai/runner.rb', line 300 def full_run? pattern_subjects.empty? && @since.nil? && !survivor_rerun? end |
#generate_mutants(subjects) ⇒ Object
100 101 102 |
# File 'lib/henitai/runner.rb', line 100 def generate_mutants(subjects) mutant_generator.generate(subjects, operators, config:) end |
#git_diff_analyzer ⇒ Object
203 |
# File 'lib/henitai/runner.rb', line 203 def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new |
#history_store ⇒ Object
228 229 230 231 232 |
# File 'lib/henitai/runner.rb', line 228 def history_store @history_store ||= MutantHistoryStore.new( path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage: ) end |
#history_store_path ⇒ String
1083 |
# File 'sig/henitai.rbs', line 1083
def history_store_path: () -> String
|
#included_source_files ⇒ Array[String]
245 246 247 248 249 |
# File 'lib/henitai/runner.rb', line 245 def included_source_files Array(config.includes).flat_map do |include_path| Dir.glob(File.join(include_path, "**", "*.rb")) end.uniq end |
#integration ⇒ Object
213 214 215 |
# File 'lib/henitai/runner.rb', line 213 def integration @integration ||= Integration.for(config.integration).new end |
#mutant_generator ⇒ Object
205 |
# File 'lib/henitai/runner.rb', line 205 def mutant_generator = @mutant_generator ||= MutantGenerator.new |
#mutants_for(subjects, source_files) ⇒ Array[Mutant]
108 109 110 111 112 113 114 115 116 |
# File 'lib/henitai/runner.rb', line 108 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 |
#normalize_path(path) ⇒ String
284 285 286 |
# File 'lib/henitai/runner.rb', line 284 def normalize_path(path) File.(path) end |
#operators ⇒ Object
217 218 219 |
# File 'lib/henitai/runner.rb', line 217 def operators @operators ||= Operator.for_set(config.operators) end |
#pattern_subjects ⇒ Array[Subject]
276 277 278 |
# File 'lib/henitai/runner.rb', line 276 def pattern_subjects Array(@subjects) end |
#per_test_coverage ⇒ PerTestCoverage
One shared live view of the per-test coverage map: the incremental filter proves survivor reuse against it and the history store records the same intersection set — one implementation, one snapshot.
237 238 239 |
# File 'lib/henitai/runner.rb', line 237 def per_test_coverage @per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir) end |
#persist_history(result, recorded_at) ⇒ void
This method returns an undefined value.
146 147 148 149 150 151 152 |
# File 'lib/henitai/runner.rb', line 146 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.
70 71 72 73 74 75 76 77 78 |
# File 'lib/henitai/runner.rb', line 70 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?
Fans progress out to the terminal reporter (when enabled) and the checkpoint writer (when enabled and a file report is configured), so a long run persists partial results incrementally.
224 225 226 |
# File 'lib/henitai/runner.rb', line 224 def progress_reporter CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?) end |
#reject_excluded(files) ⇒ Array[String]
Drops files matched by any excludes: glob (e.g. standalone entry points
that cannot be mutation-tested in-process). Excludes apply regardless of
the --since filter.
254 255 256 257 258 259 |
# File 'lib/henitai/runner.rb', line 254 def reject_excluded(files) excluded = excluded_source_files return files if excluded.empty? files.reject { |path| excluded.include?(normalize_path(path)) } end |
#report(result) ⇒ void
This method returns an undefined value.
142 143 144 |
# File 'lib/henitai/runner.rb', line 142 def report(result) Reporter.run_all(names: config.reporters, result:, config:, history_store:) end |
#resolve_subjects(source_files = self.source_files) ⇒ Object
90 91 92 93 94 95 96 97 98 |
# File 'lib/henitai/runner.rb', line 90 def resolve_subjects(source_files = self.source_files) subjects = subject_resolver.resolve_from_files(source_files) return subjects if pattern_subjects.empty? selected_subjects = pattern_subjects.flat_map do |pattern| subject_resolver.apply_pattern(subjects, pattern.expression) end unique_subjects(selected_subjects) end |
#result_thresholds ⇒ Hash[Symbol, Integer]?
288 289 290 291 292 |
# File 'lib/henitai/runner.rb', line 288 def result_thresholds return nil unless config.respond_to?(:thresholds) config.thresholds end |
#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).
55 56 57 58 59 60 61 62 63 64 |
# File 'lib/henitai/runner.rb', line 55 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?
189 190 191 192 193 194 195 |
# File 'lib/henitai/runner.rb', line 189 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_files ⇒ Array[String]
241 242 243 |
# File 'lib/henitai/runner.rb', line 241 def source_files @source_files ||= filter_changed(reject_excluded(included_source_files)) end |
#source_provider ⇒ ^(String) -> String
Reads each source file once and caches it, so Result consumes source content while performing no disk IO of its own. Returns "" for files that cannot be read (e.g. recipe stubs with synthetic locations).
178 179 180 181 182 183 184 185 186 187 |
# File 'lib/henitai/runner.rb', line 178 def source_provider cache = {} # : Hash[String, String] lambda do |file| cache[file] ||= begin File.read(file) rescue StandardError "" end end end |
#static_filter ⇒ Object
207 |
# File 'lib/henitai/runner.rb', line 207 def static_filter = @static_filter ||= StaticFilter.new |
#subject_resolver ⇒ Object
201 |
# File 'lib/henitai/runner.rb', line 201 def subject_resolver = @subject_resolver ||= SubjectResolver.new |
#survivor_rerun? ⇒ Boolean
294 295 296 |
# File 'lib/henitai/runner.rb', line 294 def survivor_rerun? !@survivors_from.nil? end |
#survivor_strategy ⇒ SurvivorRerunStrategy
304 305 306 307 308 309 310 |
# File 'lib/henitai/runner.rb', line 304 def survivor_strategy @survivor_strategy ||= SurvivorRerunStrategy.new( survivors_from: @survivors_from, config:, git_diff_analyzer: ) end |
#unique_subjects(subjects) ⇒ Array[Subject]
280 281 282 |
# File 'lib/henitai/runner.rb', line 280 def unique_subjects(subjects) subjects.uniq { |subject| [subject.expression, subject.source_file] } end |
#with_reports_dir { ... } ⇒ Object
1105 |
# File 'sig/henitai.rbs', line 1105
def with_reports_dir: () { () -> untyped } -> untyped
|