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: {}, 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.

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: {})
  • deps: (RunnerDependencies, nil) (defaults to: nil)


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

#configConfiguration (readonly)

Returns the value of attribute config.

Returns:



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

def config
  @config
end

#depsRunnerDependencies (readonly)

Returns the value of attribute deps.

Returns:



196
197
198
# File 'lib/henitai/runner.rb', line 196

def deps
  @deps
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:



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.

Parameters:

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


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

Parameters:

  • (Array[String])

Returns:

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

Parameters:

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

Returns:



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

Parameters:

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

Returns:



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_bootstrapperCoverageBootstrapper



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.

Parameters:

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

Returns:



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

Parameters:

  • (Object)

Returns:

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

Returns:

  • (Object)


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

def execution_engine = deps.execution_engine

#filter_mutants(mutants) ⇒ Object

Parameters:

  • (Object)

Returns:

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

Returns:

  • (Boolean)


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

Parameters:

  • (Object)

Returns:

  • (Object)


107
108
109
# File 'lib/henitai/runner.rb', line 107

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

#git_diff_analyzerObject

Returns:

  • (Object)


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

def git_diff_analyzer = deps.git_diff_analyzer

#history_storeObject

Returns:

  • (Object)


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

def history_store = deps.history_store

#history_store_pathString

Returns:

  • (String)


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

def history_store_path: () -> String

#integrationObject

Returns:

  • (Object)


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

def integration = deps.integration

#mutant_generatorObject

Returns:

  • (Object)


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

def mutant_generator = deps.mutant_generator

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

Parameters:

Returns:



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

#operatorsObject

Returns:

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

Parameters:

  • (Symbol)

Returns:

  • (Object)


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

def optional_config(name) = config.respond_to?(name) ? config.public_send(name) : nil

#per_test_coveragePerTestCoverage

Returns:



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.

Parameters:



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

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

Returns:



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

Returns:

  • (Object, nil)


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.

Parameters:



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

Parameters:

  • (Array[String])

Returns:

  • (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_criteriaHash[Symbol, bool]?

Returns:

  • (Hash[Symbol, bool], nil)


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

def result_coverage_criteria = optional_config(:coverage_criteria)

#result_thresholdsHash[Symbol, Integer]?

Returns:

  • (Hash[Symbol, Integer], nil)


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

def result_thresholds = optional_config(:thresholds)

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



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

Returns:

  • (String, nil)


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_selectionSourceFileSelection

Returns:



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

Returns:

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

Returns:

  • (^(String) -> String)


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

def source_provider = deps.source_provider

#static_filterObject

Returns:

  • (Object)


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

def static_filter = deps.static_filter

#subject_resolverObject

Returns:

  • (Object)


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

def subject_resolver = deps.subject_resolver

#subject_selectionSubjectSelection

Returns:



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

Returns:

  • (Boolean)


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

def survivor_rerun? = !@survivors_from.nil?

#survivor_strategySurvivorRerunStrategy



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

Yields:

Yield Returns:

  • (Object)

Returns:

  • (Object)


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

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