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)


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

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
# 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_bootstrapperCoverageBootstrapper



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.

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


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

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)


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

def execution_engine = @execution_engine ||= ExecutionEngine.new

#filter_changed(files) ⇒ Array[String]

Parameters:

  • (Array[String])

Returns:

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

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)


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

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)


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

def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new

#history_storeObject

Returns:

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

Returns:

  • (String)


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

def history_store_path: () -> String

#included_source_filesArray[String]

Returns:

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

#integrationObject

Returns:

  • (Object)


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

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

#mutant_generatorObject

Returns:

  • (Object)


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

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)


284
285
286
# File 'lib/henitai/runner.rb', line 284

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

#operatorsObject

Returns:

  • (Object)


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

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

#pattern_subjectsArray[Subject]

Returns:



276
277
278
# File 'lib/henitai/runner.rb', line 276

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:



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.

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)


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.

Parameters:

  • (Array[String])

Returns:

  • (Array[String])


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.

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)


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

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


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

Returns:

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

Returns:

  • (^(String) -> String)


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_filterObject

Returns:

  • (Object)


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

def static_filter = @static_filter ||= StaticFilter.new

#subject_resolverObject

Returns:

  • (Object)


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

def subject_resolver = @subject_resolver ||= SubjectResolver.new

#survivor_rerun?Boolean

Returns:

  • (Boolean)


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

def survivor_rerun?
  !@survivors_from.nil?
end

#survivor_strategySurvivorRerunStrategy



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]

Parameters:

Returns:



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

Yields:

Yield Returns:

  • (Object)

Returns:

  • (Object)


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

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