Class: Henitai::Runner

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil, mode: {}) ⇒ Runner

Returns a new instance of Runner.

Parameters:

  • mode (Hash) (defaults to: {})

    execution-mode flags: dry_run: stops before Gate 4, incremental: reuses still-valid Killed verdicts from history.

  • config: (Configuration) (defaults to: Configuration.load)
  • subjects: (Array[Subject]) (defaults to: nil)
  • since: (String) (defaults to: nil)
  • survivors_from: (String, nil) (defaults to: nil)
  • mode: (Hash[Symbol, bool]) (defaults to: {})


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

#configConfiguration (readonly)

Returns the value of attribute config.

Returns:



29
30
31
# File 'lib/henitai/runner.rb', line 29

def config
  @config
end

#resultObject (readonly)

Returns the value of attribute result.

Returns:

  • (Object)


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.

Parameters:

Returns:



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.

Parameters:

  • (Array[String])
  • (Array[String], nil)


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

Parameters:

  • (Array[String])

Returns:

  • (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

Parameters:

  • (Array[Mutant])
  • (Time)
  • (Time)

Returns:



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

Parameters:

  • (Array[Mutant])
  • (Time)
  • (Time)

Returns:



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_sinceArray[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".

Returns:

  • (Array[String])


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_bootstrapperCoverageBootstrapper



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.

Parameters:

  • (Array[String])

Returns:

  • (Array[String])


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.

Parameters:

  • (Array[Mutant])
  • (Time)
  • (Time)

Returns:



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_filesArray[String]

Returns:

  • (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

Parameters:

  • (Object)

Returns:

  • (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_engineObject

Returns:

  • (Object)


210
# File 'lib/henitai/runner.rb', line 210

def execution_engine = @execution_engine ||= ExecutionEngine.new

#filter_changed(files) ⇒ Array[String]

Parameters:

  • (Array[String])

Returns:

  • (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

Parameters:

  • (Object)

Returns:

  • (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".

Returns:

  • (Boolean)


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

Parameters:

  • (Object)

Returns:

  • (Object)


100
101
102
# File 'lib/henitai/runner.rb', line 100

def generate_mutants(subjects)
  mutant_generator.generate(subjects, operators, config:)
end

#git_diff_analyzerObject

Returns:

  • (Object)


204
# File 'lib/henitai/runner.rb', line 204

def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new

#history_storeObject

Returns:

  • (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_pathString

Returns:

  • (String)


1088
# File 'sig/henitai.rbs', line 1088

def history_store_path: () -> String

#included_source_filesArray[String]

Returns:

  • (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

#integrationObject

Returns:

  • (Object)


214
215
216
# File 'lib/henitai/runner.rb', line 214

def integration
  @integration ||= Integration.for(config.integration).new
end

#mutant_generatorObject

Returns:

  • (Object)


206
# File 'lib/henitai/runner.rb', line 206

def mutant_generator = @mutant_generator ||= MutantGenerator.new

#mutants_for(subjects, source_files) ⇒ Array[Mutant]

Parameters:

Returns:



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

Parameters:

  • (String)

Returns:

  • (String)


301
302
303
# File 'lib/henitai/runner.rb', line 301

def normalize_path(path)
  File.expand_path(path)
end

#operatorsObject

Returns:

  • (Object)


218
219
220
# File 'lib/henitai/runner.rb', line 218

def operators
  @operators ||= Operator.for_set(config.operators)
end

#pattern_subjectsArray[Subject]

Returns:



293
294
295
# File 'lib/henitai/runner.rb', line 293

def pattern_subjects
  Array(@subjects)
end

#per_test_coveragePerTestCoverage

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.

Returns:



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.

Parameters:



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_mutantsArray[Mutant]

Gates 0–3 only: coverage bootstrap, subject resolution, generation and static/skip/arid/stillborn filtering — everything short of execution.

Returns:



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_reporterObject?

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.

Returns:

  • (Object, nil)


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.

Parameters:

  • (Array[String])

Returns:

  • (Array[String])


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.

Parameters:



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

Parameters:

  • (Array[String])

Returns:

  • (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_thresholdsHash[Symbol, Integer]?

Returns:

  • (Hash[Symbol, Integer], nil)


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

#runResult

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

Returns:



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_shaString?

Returns:

  • (String, nil)


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_filesArray[String]

Returns:

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

Returns:

  • (^(String) -> String)


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_filterObject

Returns:

  • (Object)


208
# File 'lib/henitai/runner.rb', line 208

def static_filter = @static_filter ||= StaticFilter.new

#subject_resolverObject

Returns:

  • (Object)


202
# File 'lib/henitai/runner.rb', line 202

def subject_resolver = @subject_resolver ||= SubjectResolver.new

#survivor_rerun?Boolean

Returns:

  • (Boolean)


311
312
313
# File 'lib/henitai/runner.rb', line 311

def survivor_rerun?
  !@survivors_from.nil?
end

#survivor_strategySurvivorRerunStrategy



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]

Parameters:

Returns:



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

Yields:

Yield Returns:

  • (Object)

Returns:

  • (Object)


1112
# File 'sig/henitai.rbs', line 1112

def with_reports_dir: () { () -> untyped } -> untyped