henitai 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/README.md +123 -2
- data/assets/schema/henitai.schema.json +35 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +14 -8
- data/lib/henitai/cli/command_support.rb +2 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/options.rb +21 -57
- data/lib/henitai/cli/run_command.rb +36 -3
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +12 -4
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +41 -9
- data/lib/henitai/configuration_validator/rules.rb +18 -0
- data/lib/henitai/configuration_validator/scalars.rb +46 -0
- data/lib/henitai/configuration_validator.rb +8 -1
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +111 -50
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/child_debug_support.rb +6 -2
- data/lib/henitai/integration/coverage_suppression.rb +9 -0
- data/lib/henitai/integration/minitest.rb +11 -43
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +3 -3
- data/lib/henitai/integration/rspec_test_selection.rb +3 -0
- data/lib/henitai/integration/scenario_log_support.rb +64 -20
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +14 -0
- data/lib/henitai/mutant.rb +13 -6
- data/lib/henitai/mutant_history_store/sql.rb +21 -3
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +62 -12
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +66 -0
- data/lib/henitai/per_test_coverage_collector.rb +20 -5
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +238 -117
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +72 -9
- data/lib/henitai/runner.rb +82 -40
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +21 -15
- data/lib/henitai/slot_scheduler.rb +73 -15
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +1 -1
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +14 -1
- data/sig/henitai.rbs +230 -25
- metadata +22 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Henitai
|
|
8
|
+
# Coordinates exclusive access to a reports directory across processes.
|
|
9
|
+
class ReportsDirectoryLock
|
|
10
|
+
LOCK_FILENAME = ".henitai-run.lock"
|
|
11
|
+
|
|
12
|
+
def initialize(reports_dir:)
|
|
13
|
+
@reports_dir = File.expand_path(reports_dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def synchronize
|
|
17
|
+
FileUtils.mkdir_p(@reports_dir)
|
|
18
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
19
|
+
acquire(file)
|
|
20
|
+
write_owner(file)
|
|
21
|
+
yield
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def lock_path
|
|
28
|
+
File.join(@reports_dir, LOCK_FILENAME)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def acquire(file)
|
|
32
|
+
return if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
33
|
+
|
|
34
|
+
raise ConcurrentRunError, contention_message(owner_pid(file))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def owner_pid(file)
|
|
38
|
+
file.rewind
|
|
39
|
+
metadata = JSON.parse(file.read)
|
|
40
|
+
pid = metadata["pid"] if metadata.is_a?(Hash)
|
|
41
|
+
pid.is_a?(Integer) ? pid : "unknown"
|
|
42
|
+
rescue JSON::ParserError, IOError, SystemCallError
|
|
43
|
+
"unknown"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def contention_message(pid)
|
|
47
|
+
message = "another henitai run is active in #{@reports_dir} " \
|
|
48
|
+
"(pid #{pid}); use a separate reports_dir or wait"
|
|
49
|
+
return message unless dead_owner?(pid)
|
|
50
|
+
|
|
51
|
+
"#{message}. The recorded owner pid #{pid} is not running — an " \
|
|
52
|
+
"orphaned child may still hold the lock " \
|
|
53
|
+
"(check: lsof #{lock_path})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# True only when the recorded owner pid provably no longer exists. EPERM
|
|
57
|
+
# means the process is alive but owned by someone else — treated as alive.
|
|
58
|
+
def dead_owner?(pid)
|
|
59
|
+
return false unless pid.is_a?(Integer)
|
|
60
|
+
|
|
61
|
+
Process.kill(0, pid)
|
|
62
|
+
false
|
|
63
|
+
rescue Errno::ESRCH
|
|
64
|
+
true
|
|
65
|
+
rescue StandardError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write_owner(file)
|
|
70
|
+
file.rewind
|
|
71
|
+
file.truncate(0)
|
|
72
|
+
file.write(JSON.generate(pid: Process.pid, started_at: Time.now.iso8601))
|
|
73
|
+
file.flush
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/henitai/result.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "mutant_identity"
|
|
4
5
|
require_relative "unparse_helper"
|
|
5
6
|
|
|
6
7
|
module Henitai
|
|
@@ -22,11 +23,16 @@ module Henitai
|
|
|
22
23
|
# already knows the file paths) supplies the contents. The default
|
|
23
24
|
# provider returns "" for every file — callers that need real source in
|
|
24
25
|
# the schema (e.g. the runner) inject a provider primed with file content.
|
|
26
|
+
# @param authoritative [Boolean] whether this run covers the full
|
|
27
|
+
# configured mutation scope. Authoritative runs fully replace the
|
|
28
|
+
# canonical report; non-authoritative (subject-scoped, --since,
|
|
29
|
+
# --survivors-from) runs are merged into it instead — see
|
|
30
|
+
# CanonicalReportMerger.
|
|
25
31
|
# rubocop:disable Metrics/ParameterLists
|
|
26
32
|
def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
|
|
27
33
|
partial_rerun: false, survivor_stats: nil,
|
|
28
34
|
session_id: SecureRandom.uuid, git_sha: nil,
|
|
29
|
-
source_provider: ->(_file) { "" })
|
|
35
|
+
source_provider: ->(_file) { "" }, authoritative: true)
|
|
30
36
|
@mutants = mutants
|
|
31
37
|
@started_at = started_at
|
|
32
38
|
@finished_at = finished_at
|
|
@@ -36,10 +42,12 @@ module Henitai
|
|
|
36
42
|
@session_id = session_id
|
|
37
43
|
@git_sha = git_sha
|
|
38
44
|
@source_provider = source_provider
|
|
45
|
+
@authoritative = authoritative
|
|
39
46
|
end
|
|
40
47
|
# rubocop:enable Metrics/ParameterLists
|
|
41
48
|
|
|
42
49
|
def partial_rerun? = @partial_rerun
|
|
50
|
+
def authoritative? = @authoritative
|
|
43
51
|
|
|
44
52
|
# @return [Integer] number of killed mutants
|
|
45
53
|
def killed = mutants.count(&:killed?)
|
|
@@ -53,7 +61,7 @@ module Henitai
|
|
|
53
61
|
# Detected = killed + timeout + runtime_error (alle Zustände die einen Fehler beweisen)
|
|
54
62
|
# @return [Integer]
|
|
55
63
|
def detected
|
|
56
|
-
mutants
|
|
64
|
+
detected_in(mutants)
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
# Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
|
|
@@ -66,11 +74,7 @@ module Henitai
|
|
|
66
74
|
#
|
|
67
75
|
# @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
|
|
68
76
|
def mutation_score
|
|
69
|
-
|
|
70
|
-
valid = mutants.reject { |m| excluded.include?(m.status) }
|
|
71
|
-
return nil if valid.empty?
|
|
72
|
-
|
|
73
|
-
((detected.to_f / valid.count) * 100.0).round(2).to_f
|
|
77
|
+
mutation_score_for(mutants)
|
|
74
78
|
end
|
|
75
79
|
|
|
76
80
|
# Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
|
|
@@ -82,9 +86,23 @@ module Henitai
|
|
|
82
86
|
#
|
|
83
87
|
# @return [Float, nil]
|
|
84
88
|
def mutation_score_indicator
|
|
85
|
-
|
|
89
|
+
mutation_score_indicator_for(mutants)
|
|
90
|
+
end
|
|
86
91
|
|
|
87
|
-
|
|
92
|
+
# Scores over only the mutants actually executed this run — verdicts
|
|
93
|
+
# reused from the history store (`--incremental`) excluded. nil when
|
|
94
|
+
# nothing was reused: the combined scores already tell the whole story,
|
|
95
|
+
# and reporters use nil to suppress the extra line.
|
|
96
|
+
#
|
|
97
|
+
# @return [Hash, nil] { mutation_score:, mutation_score_indicator: }
|
|
98
|
+
def executed_scoring_summary
|
|
99
|
+
executed = mutants.reject { |m| m.respond_to?(:from_cache?) && m.from_cache? }
|
|
100
|
+
return nil if executed.size == mutants.size
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
mutation_score: mutation_score_for(executed),
|
|
104
|
+
mutation_score_indicator: mutation_score_indicator_for(executed)
|
|
105
|
+
}
|
|
88
106
|
end
|
|
89
107
|
|
|
90
108
|
# Compact public summary for reporters.
|
|
@@ -119,6 +137,24 @@ module Henitai
|
|
|
119
137
|
|
|
120
138
|
private
|
|
121
139
|
|
|
140
|
+
def detected_in(list)
|
|
141
|
+
list.count { |m| %i[killed timeout runtime_error].include?(m.status) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def mutation_score_for(list)
|
|
145
|
+
excluded = %i[ignored no_coverage compile_error equivalent]
|
|
146
|
+
valid = list.reject { |m| excluded.include?(m.status) }
|
|
147
|
+
return nil if valid.empty?
|
|
148
|
+
|
|
149
|
+
((detected_in(list).to_f / valid.count) * 100.0).round(2).to_f
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def mutation_score_indicator_for(list)
|
|
153
|
+
return nil if list.empty?
|
|
154
|
+
|
|
155
|
+
((list.count(&:killed?).to_f / list.count) * 100.0).round(2).to_f
|
|
156
|
+
end
|
|
157
|
+
|
|
122
158
|
def base_schema
|
|
123
159
|
{ # : Hash[Symbol, untyped]
|
|
124
160
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -148,10 +184,13 @@ module Henitai
|
|
|
148
184
|
{
|
|
149
185
|
id: mutant.id,
|
|
150
186
|
stableId: mutant.stable_id,
|
|
187
|
+
legacyStableId: legacy_stable_id_for(mutant),
|
|
151
188
|
mutatorName: mutant.operator,
|
|
152
189
|
replacement: replacement_for(mutant),
|
|
153
190
|
location: location_for(mutant),
|
|
154
191
|
status: stryker_status(mutant.status),
|
|
192
|
+
statusReason: status_reason_for(mutant),
|
|
193
|
+
fromCache: from_cache_for(mutant),
|
|
155
194
|
description: mutant.description,
|
|
156
195
|
duration: duration_for(mutant)
|
|
157
196
|
}.compact.merge(coverage_schema(mutant))
|
|
@@ -168,6 +207,8 @@ module Henitai
|
|
|
168
207
|
end
|
|
169
208
|
|
|
170
209
|
def replacement_for(mutant)
|
|
210
|
+
return mutant.mutated_source if mutant.respond_to?(:mutated_source)
|
|
211
|
+
|
|
171
212
|
safe_unparse(mutant.mutated_node)
|
|
172
213
|
end
|
|
173
214
|
|
|
@@ -190,6 +231,28 @@ module Henitai
|
|
|
190
231
|
mutant.duration&.then { |d| (d * 1000).round }
|
|
191
232
|
end
|
|
192
233
|
|
|
234
|
+
# Serialized as the schema's statusReason so directive reasons show up
|
|
235
|
+
# next to the Ignored status in the HTML report.
|
|
236
|
+
def status_reason_for(mutant)
|
|
237
|
+
return nil unless mutant.respond_to?(:ignore_reason)
|
|
238
|
+
|
|
239
|
+
mutant.ignore_reason
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Vendored extension field (like stableId): marks verdicts reused from
|
|
243
|
+
# the history store so reports stay honest about what actually executed.
|
|
244
|
+
def from_cache_for(mutant)
|
|
245
|
+
return nil unless mutant.respond_to?(:from_cache?)
|
|
246
|
+
|
|
247
|
+
mutant.from_cache? || nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def legacy_stable_id_for(mutant)
|
|
251
|
+
return if mutant.respond_to?(:precomputed_stable_id) && mutant.precomputed_stable_id
|
|
252
|
+
|
|
253
|
+
MutantIdentity.legacy_stable_id(mutant)
|
|
254
|
+
end
|
|
255
|
+
|
|
193
256
|
def equivalence_uncertainty
|
|
194
257
|
return nil if mutation_score.nil?
|
|
195
258
|
|
data/lib/henitai/runner.rb
CHANGED
|
@@ -28,11 +28,16 @@ module Henitai
|
|
|
28
28
|
class Runner
|
|
29
29
|
attr_reader :config, :result
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
# @param mode [Hash] execution-mode flags: +dry_run:+ stops before Gate 4,
|
|
32
|
+
# +incremental:+ reuses still-valid Killed verdicts from history.
|
|
33
|
+
def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil,
|
|
34
|
+
mode: {})
|
|
32
35
|
@config = config
|
|
33
36
|
@subjects = subjects
|
|
34
37
|
@since = since
|
|
35
38
|
@survivors_from = survivors_from
|
|
39
|
+
@dry_run = mode.fetch(:dry_run, false)
|
|
40
|
+
@incremental = mode.fetch(:incremental, false)
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
# Entry point — runs the full pipeline and returns a Result.
|
|
@@ -48,21 +53,40 @@ module Henitai
|
|
|
48
53
|
#
|
|
49
54
|
# @return [Result]
|
|
50
55
|
def run
|
|
51
|
-
|
|
56
|
+
ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do
|
|
57
|
+
started_at = Time.now
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
else
|
|
56
|
-
source_files = self.source_files
|
|
57
|
-
subjects = resolve_subjects(source_files)
|
|
58
|
-
execute_mutants(mutants_for(subjects, source_files))
|
|
59
|
-
end
|
|
59
|
+
mutants = pipeline_mutants
|
|
60
|
+
return dry_run_result(mutants, started_at, Time.now) if @dry_run
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
build_result(execute_mutants(mutants), started_at, Time.now)
|
|
63
|
+
end
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
private
|
|
65
67
|
|
|
68
|
+
# Gates 0–3 only: coverage bootstrap, subject resolution, generation and
|
|
69
|
+
# static/skip/arid/stillborn filtering — everything short of execution.
|
|
70
|
+
def pipeline_mutants
|
|
71
|
+
if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run)
|
|
72
|
+
return fast_mutants
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
source_files = self.source_files
|
|
76
|
+
subjects = resolve_subjects(source_files)
|
|
77
|
+
mutants_for(subjects, source_files)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Dry run stops before Gate 4: prints the post-filter listing and returns
|
|
81
|
+
# a Result without executing mutants, persisting history or running the
|
|
82
|
+
# configured reporters. Gate 0 and lock coordination may still write under
|
|
83
|
+
# reports_dir.
|
|
84
|
+
def dry_run_result(mutants, started_at, finished_at)
|
|
85
|
+
@result = build_result_object(mutants, started_at, finished_at)
|
|
86
|
+
Reporter::DryRun.new(config:).report(@result)
|
|
87
|
+
@result
|
|
88
|
+
end
|
|
89
|
+
|
|
66
90
|
def resolve_subjects(source_files = self.source_files)
|
|
67
91
|
subjects = subject_resolver.resolve_from_files(source_files)
|
|
68
92
|
return subjects if pattern_subjects.empty?
|
|
@@ -85,16 +109,27 @@ module Henitai
|
|
|
85
109
|
bootstrap_thread = bootstrap_mutants(source_files)
|
|
86
110
|
mutants = generate_mutants(subjects)
|
|
87
111
|
bootstrap_thread.value
|
|
88
|
-
filtered = filter_mutants(mutants)
|
|
112
|
+
filtered = apply_incremental_filter(filter_mutants(mutants))
|
|
89
113
|
return filtered unless survivor_rerun?
|
|
90
114
|
|
|
91
115
|
survivor_strategy.apply_selection(filtered)
|
|
92
116
|
end
|
|
93
117
|
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
# Opt-in verdict reuse (`--incremental`): still-valid Killed and Survived
|
|
119
|
+
# verdicts from the history store are marked with their stored status +
|
|
120
|
+
# from_cache before execution. The filter is only ever constructed when
|
|
121
|
+
# the flag is set; it runs after the coverage bootstrap join in
|
|
122
|
+
# mutants_for, so the live per-test map it reads is never mid-write.
|
|
123
|
+
def apply_incremental_filter(mutants)
|
|
124
|
+
return mutants unless @incremental
|
|
125
|
+
|
|
126
|
+
IncrementalFilter.new(history_store:, per_test_coverage:,
|
|
127
|
+
dependency_fingerprint: VerdictFingerprint.dependency_fingerprint)
|
|
128
|
+
.apply(mutants)
|
|
96
129
|
end
|
|
97
130
|
|
|
131
|
+
def bootstrap_mutants(source_files) = Thread.new { bootstrap_coverage(source_files) }
|
|
132
|
+
|
|
98
133
|
def execute_mutants(mutants)
|
|
99
134
|
execution_engine.run(
|
|
100
135
|
mutants,
|
|
@@ -117,7 +152,14 @@ module Henitai
|
|
|
117
152
|
end
|
|
118
153
|
|
|
119
154
|
def build_result(mutants, started_at, finished_at)
|
|
120
|
-
@result =
|
|
155
|
+
@result = build_result_object(mutants, started_at, finished_at)
|
|
156
|
+
persist_history(@result, finished_at)
|
|
157
|
+
report(@result)
|
|
158
|
+
@result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_result_object(mutants, started_at, finished_at)
|
|
162
|
+
Result.new(
|
|
121
163
|
mutants:,
|
|
122
164
|
started_at:,
|
|
123
165
|
finished_at:,
|
|
@@ -125,11 +167,9 @@ module Henitai
|
|
|
125
167
|
partial_rerun: survivor_rerun?,
|
|
126
168
|
survivor_stats: survivor_strategy.survivor_stats,
|
|
127
169
|
git_sha: safe_head_sha,
|
|
128
|
-
source_provider: source_provider
|
|
170
|
+
source_provider: source_provider,
|
|
171
|
+
authoritative: full_run?
|
|
129
172
|
)
|
|
130
|
-
persist_history(@result, finished_at)
|
|
131
|
-
report(@result)
|
|
132
|
-
@result
|
|
133
173
|
end
|
|
134
174
|
|
|
135
175
|
# Reads each source file once and caches it, so Result consumes source
|
|
@@ -158,29 +198,17 @@ module Henitai
|
|
|
158
198
|
coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
|
|
159
199
|
end
|
|
160
200
|
|
|
161
|
-
def subject_resolver
|
|
162
|
-
@subject_resolver ||= SubjectResolver.new
|
|
163
|
-
end
|
|
201
|
+
def subject_resolver = @subject_resolver ||= SubjectResolver.new
|
|
164
202
|
|
|
165
|
-
def git_diff_analyzer
|
|
166
|
-
@git_diff_analyzer ||= GitDiffAnalyzer.new
|
|
167
|
-
end
|
|
203
|
+
def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
|
|
168
204
|
|
|
169
|
-
def mutant_generator
|
|
170
|
-
@mutant_generator ||= MutantGenerator.new
|
|
171
|
-
end
|
|
205
|
+
def mutant_generator = @mutant_generator ||= MutantGenerator.new
|
|
172
206
|
|
|
173
|
-
def static_filter
|
|
174
|
-
@static_filter ||= StaticFilter.new
|
|
175
|
-
end
|
|
207
|
+
def static_filter = @static_filter ||= StaticFilter.new
|
|
176
208
|
|
|
177
|
-
def execution_engine
|
|
178
|
-
@execution_engine ||= ExecutionEngine.new
|
|
179
|
-
end
|
|
209
|
+
def execution_engine = @execution_engine ||= ExecutionEngine.new
|
|
180
210
|
|
|
181
|
-
def coverage_bootstrapper
|
|
182
|
-
@coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
183
|
-
end
|
|
211
|
+
def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
184
212
|
|
|
185
213
|
def integration
|
|
186
214
|
@integration ||= Integration.for(config.integration).new
|
|
@@ -190,18 +218,26 @@ module Henitai
|
|
|
190
218
|
@operators ||= Operator.for_set(config.operators)
|
|
191
219
|
end
|
|
192
220
|
|
|
221
|
+
# Fans progress out to the terminal reporter (when enabled) and the
|
|
222
|
+
# checkpoint writer (when enabled and a file report is configured), so a
|
|
223
|
+
# long run persists partial results incrementally.
|
|
193
224
|
def progress_reporter
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
Reporter::Terminal.new(config:)
|
|
225
|
+
CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?)
|
|
197
226
|
end
|
|
198
227
|
|
|
199
228
|
def history_store
|
|
200
229
|
@history_store ||= MutantHistoryStore.new(
|
|
201
|
-
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
|
|
230
|
+
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage:
|
|
202
231
|
)
|
|
203
232
|
end
|
|
204
233
|
|
|
234
|
+
# One shared live view of the per-test coverage map: the incremental
|
|
235
|
+
# filter proves survivor reuse against it and the history store records
|
|
236
|
+
# the same intersection set — one implementation, one snapshot.
|
|
237
|
+
def per_test_coverage
|
|
238
|
+
@per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir)
|
|
239
|
+
end
|
|
240
|
+
|
|
205
241
|
def source_files
|
|
206
242
|
@source_files ||= filter_changed(reject_excluded(included_source_files))
|
|
207
243
|
end
|
|
@@ -259,6 +295,12 @@ module Henitai
|
|
|
259
295
|
!@survivors_from.nil?
|
|
260
296
|
end
|
|
261
297
|
|
|
298
|
+
# Mutation-scope full run, controlling Result#authoritative? — distinct
|
|
299
|
+
# from the per-test-coverage plan's test-suite-scope "full run".
|
|
300
|
+
def full_run?
|
|
301
|
+
pattern_subjects.empty? && @since.nil? && !survivor_rerun?
|
|
302
|
+
end
|
|
303
|
+
|
|
262
304
|
def survivor_strategy
|
|
263
305
|
@survivor_strategy ||= SurvivorRerunStrategy.new(
|
|
264
306
|
survivors_from: @survivors_from,
|
|
@@ -23,6 +23,18 @@ module Henitai
|
|
|
23
23
|
@exit_status = exit_status
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Frees the captured child output once it has been consumed (e.g. by the
|
|
27
|
+
# progress reporter). The scheduler retains one result per mutant for the
|
|
28
|
+
# whole run; without this a runaway mutant's multi-hundred-MB output would
|
|
29
|
+
# stay resident until the run ends. Status/log_path/exit_status are kept so
|
|
30
|
+
# the result stays usable for scoring and log lookups.
|
|
31
|
+
def release_output!
|
|
32
|
+
@stdout = ""
|
|
33
|
+
@stderr = ""
|
|
34
|
+
@log_text = nil
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
def survived?
|
|
27
39
|
status == :survived
|
|
28
40
|
end
|
|
@@ -106,24 +106,30 @@ module Henitai
|
|
|
106
106
|
# parent signal was sent. Once SIGTERM has been dispatched, the forced
|
|
107
107
|
# outcome is authoritative — a child handling SIGTERM and exiting 0 must
|
|
108
108
|
# not be misclassified as :survived.
|
|
109
|
-
def reap_and_remove_draining(draining)
|
|
110
|
-
draining.each_value
|
|
111
|
-
|
|
112
|
-
# between SIGKILL and here.
|
|
113
|
-
_, final_status = wnohang_reap(slot.pid)
|
|
114
|
-
reap_pid(slot.pid) unless final_status
|
|
109
|
+
def reap_and_remove_draining(draining)
|
|
110
|
+
draining.each_value { |slot| reap_and_finalize_slot(slot) }
|
|
111
|
+
end
|
|
115
112
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
113
|
+
# One last WNOHANG before blocking: catches processes that exited
|
|
114
|
+
# between SIGKILL and here.
|
|
115
|
+
def reap_and_finalize_slot(slot)
|
|
116
|
+
_, final_status = wnohang_reap(slot.pid)
|
|
117
|
+
reap_pid(slot.pid) unless final_status
|
|
119
118
|
|
|
120
|
-
|
|
119
|
+
pid_to_slot.delete(slot.pid)
|
|
120
|
+
slots.delete(slot.slot_id)
|
|
121
|
+
Integration::SchedulerDiagnostics.child_ended(slot.pid)
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
return if slot.forced_outcome == :interrupted
|
|
124
|
+
|
|
125
|
+
record_drain_result(slot, final_status)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def record_drain_result(slot, final_status)
|
|
129
|
+
result = build_drain_result(slot, final_status)
|
|
130
|
+
slot.mutant.status = result.status
|
|
131
|
+
results << result
|
|
132
|
+
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
127
133
|
end
|
|
128
134
|
|
|
129
135
|
# Choose result: use real exit status only if observed before any parent
|
|
@@ -23,11 +23,17 @@ module Henitai
|
|
|
23
23
|
|
|
24
24
|
PROCESS_DRAIN_WINDOW = 0.2
|
|
25
25
|
|
|
26
|
+
# Environment variable exposing a stable worker-slot index (0..jobs-1) to
|
|
27
|
+
# each forked child so test suites can isolate shared resources per slot
|
|
28
|
+
# (e.g. "myapp_test_#{ENV['HENITAI_WORKER_SLOT']}"). A flaky-retry respawn
|
|
29
|
+
# keeps the original attempt's value.
|
|
30
|
+
WORKER_SLOT_ENV = "HENITAI_WORKER_SLOT"
|
|
31
|
+
|
|
26
32
|
# Tracks one in-flight mutant child process.
|
|
27
33
|
Slot = Struct.new(
|
|
28
34
|
:slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
|
|
29
35
|
:log_paths, :retry_count, :draining, :term_sent_at_monotonic,
|
|
30
|
-
:forced_outcome
|
|
36
|
+
:forced_outcome, :worker_index
|
|
31
37
|
)
|
|
32
38
|
|
|
33
39
|
# @return [Integer] mutants that required at least one retry during the run.
|
|
@@ -98,28 +104,48 @@ module Henitai
|
|
|
98
104
|
test_files = resolve_test_files(mutant)
|
|
99
105
|
mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
|
|
100
106
|
mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
return record_no_coverage(mutant) if resolved_selection_empty?(test_files)
|
|
108
|
+
|
|
109
|
+
worker_index = next_free_worker_index
|
|
110
|
+
with_worker_slot(worker_index) do
|
|
111
|
+
handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
|
|
112
|
+
register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
|
|
113
|
+
end
|
|
103
114
|
rescue StandardError => e
|
|
104
115
|
record_spawn_failure(mutant, e)
|
|
105
116
|
end
|
|
106
117
|
|
|
107
|
-
def register_slot(handle, mutant)
|
|
118
|
+
def register_slot(handle, mutant, worker_index, timeout)
|
|
108
119
|
slot_id = next_slot_id!
|
|
109
|
-
slot = build_slot(slot_id, mutant, handle)
|
|
120
|
+
slot = build_slot(slot_id, mutant, handle, worker_index, timeout)
|
|
110
121
|
slots[slot_id] = slot
|
|
111
122
|
pid_to_slot[handle.pid] = slot_id
|
|
112
123
|
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
113
124
|
end
|
|
114
125
|
|
|
115
|
-
def build_slot(slot_id, mutant, handle)
|
|
126
|
+
def build_slot(slot_id, mutant, handle, worker_index, timeout)
|
|
116
127
|
Slot.new(
|
|
117
128
|
slot_id, mutant, handle.pid,
|
|
118
129
|
monotonic_time,
|
|
119
|
-
|
|
130
|
+
timeout, handle.log_paths, 0, false, nil, nil,
|
|
131
|
+
worker_index
|
|
120
132
|
)
|
|
121
133
|
end
|
|
122
134
|
|
|
135
|
+
def slot_timeout(mutant, test_files)
|
|
136
|
+
resolver = options[:timeout_resolver]
|
|
137
|
+
resolver ? resolver.call(mutant, test_files) : config.timeout
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Smallest index in 0...worker_count not held by a live slot, so
|
|
141
|
+
# concurrently-running children always see distinct values and freed
|
|
142
|
+
# indices are reused. Slot ids themselves grow monotonically and are
|
|
143
|
+
# unsuitable as a resource token.
|
|
144
|
+
def next_free_worker_index
|
|
145
|
+
used = slots.each_value.map(&:worker_index)
|
|
146
|
+
(0...worker_count).find { |index| !used.include?(index) } || used.size
|
|
147
|
+
end
|
|
148
|
+
|
|
123
149
|
def complete_slot(pid, wait_result)
|
|
124
150
|
slot_id = pid_to_slot.delete(pid)
|
|
125
151
|
return unless slot_id
|
|
@@ -140,6 +166,7 @@ module Henitai
|
|
|
140
166
|
slot.mutant.status = result.status
|
|
141
167
|
results << result
|
|
142
168
|
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
169
|
+
result.release_output! if result.respond_to?(:release_output!)
|
|
143
170
|
end
|
|
144
171
|
end
|
|
145
172
|
|
|
@@ -147,22 +174,32 @@ module Henitai
|
|
|
147
174
|
!shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
|
|
148
175
|
end
|
|
149
176
|
|
|
150
|
-
def retry_slot(slot)
|
|
177
|
+
def retry_slot(slot)
|
|
178
|
+
test_files = resolve_test_files(slot.mutant)
|
|
179
|
+
with_worker_slot(slot.worker_index) do
|
|
180
|
+
handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
|
|
181
|
+
finish_retry(slot, handle)
|
|
182
|
+
end
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
slots.delete(slot.slot_id)
|
|
185
|
+
record_spawn_failure(slot.mutant, e)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def finish_retry(slot, handle)
|
|
151
189
|
@flaky_retry_count += 1 if slot.retry_count.zero?
|
|
152
190
|
slot.retry_count += 1
|
|
153
|
-
|
|
154
|
-
handle =
|
|
191
|
+
reset_slot_for_retry(slot, handle)
|
|
192
|
+
pid_to_slot[handle.pid] = slot.slot_id
|
|
193
|
+
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def reset_slot_for_retry(slot, handle)
|
|
155
197
|
slot.pid = handle.pid
|
|
156
198
|
slot.log_paths = handle.log_paths
|
|
157
199
|
slot.started_at_monotonic = monotonic_time
|
|
158
200
|
slot.draining = false
|
|
159
201
|
slot.term_sent_at_monotonic = nil
|
|
160
202
|
slot.forced_outcome = nil
|
|
161
|
-
pid_to_slot[handle.pid] = slot.slot_id
|
|
162
|
-
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
163
|
-
rescue StandardError => e
|
|
164
|
-
slots.delete(slot.slot_id)
|
|
165
|
-
record_spawn_failure(slot.mutant, e)
|
|
166
203
|
end
|
|
167
204
|
|
|
168
205
|
def record_spawn_failure(mutant, error)
|
|
@@ -178,6 +215,15 @@ module Henitai
|
|
|
178
215
|
progress_reporter&.progress(mutant, scenario_result: result)
|
|
179
216
|
end
|
|
180
217
|
|
|
218
|
+
def record_no_coverage(mutant)
|
|
219
|
+
mutant.status = :no_coverage
|
|
220
|
+
progress_reporter&.progress(mutant, scenario_result: nil)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def resolved_selection_empty?(test_files)
|
|
224
|
+
options.key?(:test_file_resolver) && test_files.empty?
|
|
225
|
+
end
|
|
226
|
+
|
|
181
227
|
def remaining_slot_timeout(slot, now)
|
|
182
228
|
# Invariant: drain_draining_slots runs (and removes draining slots) before
|
|
183
229
|
# the event wait, so next_event_timeout never observes a draining slot
|
|
@@ -210,5 +256,17 @@ module Henitai
|
|
|
210
256
|
@next_slot_id += 1
|
|
211
257
|
id
|
|
212
258
|
end
|
|
259
|
+
|
|
260
|
+
def with_worker_slot(worker_index)
|
|
261
|
+
previous = ENV.fetch(WORKER_SLOT_ENV, nil)
|
|
262
|
+
ENV[WORKER_SLOT_ENV] = worker_index.to_s
|
|
263
|
+
yield
|
|
264
|
+
ensure
|
|
265
|
+
if previous.nil?
|
|
266
|
+
ENV.delete(WORKER_SLOT_ENV)
|
|
267
|
+
else
|
|
268
|
+
ENV[WORKER_SLOT_ENV] = previous
|
|
269
|
+
end
|
|
270
|
+
end
|
|
213
271
|
end
|
|
214
272
|
end
|