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
-
#changed_paths_since ⇒ Array[String]
Committed changes since the ref plus the current working tree (tracked dirty and untracked files): the working tree is what gets tested, so it is always part of "changed since REF".
- #coverage_bootstrapper ⇒ CoverageBootstrapper
-
#covered_sources_for_changed_tests(changed_paths) ⇒ Array[String]
Changed test files select the source files they cover, so an edited test re-tests the subjects it can kill.
-
#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.
198 199 200 |
# File 'lib/henitai/runner.rb', line 198 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 174 |
# 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?, since: @since ) end |
#changed_paths_since ⇒ Array[String]
Committed changes since the ref plus the current working tree (tracked dirty and untracked files): the working tree is what gets tested, so it is always part of "changed since REF".
279 280 281 282 |
# File 'lib/henitai/runner.rb', line 279 def changed_paths_since git_diff_analyzer.changed_files(from: @since, to: "HEAD") + git_diff_analyzer.working_tree_changed_files end |
#coverage_bootstrapper ⇒ CoverageBootstrapper
212 |
# File 'lib/henitai/runner.rb', line 212 def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new |
#covered_sources_for_changed_tests(changed_paths) ⇒ Array[String]
Changed test files select the source files they cover, so an edited test re-tests the subjects it can kill. Uses the per-test map from the previous run — Gate 1 runs before this run's bootstrap finishes.
287 288 289 290 291 |
# File 'lib/henitai/runner.rb', line 287 def covered_sources_for_changed_tests(changed_paths) changed_paths .flat_map { |path| per_test_coverage.source_files_covered_by(path) } .map { |path| normalize_path(path) } end |
#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]
262 263 264 265 266 |
# File 'lib/henitai/runner.rb', line 262 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
210 |
# File 'lib/henitai/runner.rb', line 210 def execution_engine = @execution_engine ||= ExecutionEngine.new |
#filter_changed(files) ⇒ Array[String]
268 269 270 271 272 273 274 |
# File 'lib/henitai/runner.rb', line 268 def filter_changed(files) return files unless @since changed_file_set = changed_paths_since.map { |path| normalize_path(path) } changed_file_set += covered_sources_for_changed_tests(changed_file_set) 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".
317 318 319 |
# File 'lib/henitai/runner.rb', line 317 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
204 |
# File 'lib/henitai/runner.rb', line 204 def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new |
#history_store ⇒ Object
229 230 231 232 233 |
# File 'lib/henitai/runner.rb', line 229 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
1088 |
# File 'sig/henitai.rbs', line 1088
def history_store_path: () -> String
|
#included_source_files ⇒ Array[String]
246 247 248 249 250 |
# File 'lib/henitai/runner.rb', line 246 def included_source_files Array(config.includes).flat_map do |include_path| Dir.glob(File.join(include_path, "**", "*.rb")) end.uniq end |
#integration ⇒ Object
214 215 216 |
# File 'lib/henitai/runner.rb', line 214 def integration @integration ||= Integration.for(config.integration).new end |
#mutant_generator ⇒ Object
206 |
# File 'lib/henitai/runner.rb', line 206 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
301 302 303 |
# File 'lib/henitai/runner.rb', line 301 def normalize_path(path) File.(path) end |
#operators ⇒ Object
218 219 220 |
# File 'lib/henitai/runner.rb', line 218 def operators @operators ||= Operator.for_set(config.operators) end |
#pattern_subjects ⇒ Array[Subject]
293 294 295 |
# File 'lib/henitai/runner.rb', line 293 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.
238 239 240 |
# File 'lib/henitai/runner.rb', line 238 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.
225 226 227 |
# File 'lib/henitai/runner.rb', line 225 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.
255 256 257 258 259 260 |
# File 'lib/henitai/runner.rb', line 255 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]?
305 306 307 308 309 |
# File 'lib/henitai/runner.rb', line 305 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?
190 191 192 193 194 195 196 |
# File 'lib/henitai/runner.rb', line 190 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]
242 243 244 |
# File 'lib/henitai/runner.rb', line 242 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).
179 180 181 182 183 184 185 186 187 188 |
# File 'lib/henitai/runner.rb', line 179 def source_provider cache = {} # : Hash[String, String] lambda do |file| cache[file] ||= begin File.read(file) rescue StandardError "" end end end |
#static_filter ⇒ Object
208 |
# File 'lib/henitai/runner.rb', line 208 def static_filter = @static_filter ||= StaticFilter.new |
#subject_resolver ⇒ Object
202 |
# File 'lib/henitai/runner.rb', line 202 def subject_resolver = @subject_resolver ||= SubjectResolver.new |
#survivor_rerun? ⇒ Boolean
311 312 313 |
# File 'lib/henitai/runner.rb', line 311 def survivor_rerun? !@survivors_from.nil? end |
#survivor_strategy ⇒ SurvivorRerunStrategy
321 322 323 324 325 326 327 |
# File 'lib/henitai/runner.rb', line 321 def survivor_strategy @survivor_strategy ||= SurvivorRerunStrategy.new( survivors_from: @survivors_from, config:, git_diff_analyzer: ) end |
#unique_subjects(subjects) ⇒ Array[Subject]
297 298 299 |
# File 'lib/henitai/runner.rb', line 297 def unique_subjects(subjects) subjects.uniq { |subject| [subject.expression, subject.source_file] } end |
#with_reports_dir { ... } ⇒ Object
1112 |
# File 'sig/henitai.rbs', line 1112
def with_reports_dir: () { () -> untyped } -> untyped
|