henitai 0.2.1 → 0.3.1
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 +106 -1
- data/README.md +125 -3
- 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 +67 -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 +81 -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 +248 -117
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +75 -10
- data/lib/henitai/runner.rb +102 -43
- 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 +235 -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
|
|
@@ -15,18 +16,24 @@ module Henitai
|
|
|
15
16
|
DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
16
17
|
|
|
17
18
|
attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
|
|
18
|
-
:session_id, :git_sha
|
|
19
|
+
:session_id, :git_sha, :since
|
|
19
20
|
|
|
20
21
|
# @param source_provider [#call] maps a file path to its source string.
|
|
21
22
|
# Injected so the domain object performs no disk IO; the caller (which
|
|
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,
|
|
36
|
+
since: nil)
|
|
30
37
|
@mutants = mutants
|
|
31
38
|
@started_at = started_at
|
|
32
39
|
@finished_at = finished_at
|
|
@@ -36,10 +43,13 @@ module Henitai
|
|
|
36
43
|
@session_id = session_id
|
|
37
44
|
@git_sha = git_sha
|
|
38
45
|
@source_provider = source_provider
|
|
46
|
+
@authoritative = authoritative
|
|
47
|
+
@since = since
|
|
39
48
|
end
|
|
40
49
|
# rubocop:enable Metrics/ParameterLists
|
|
41
50
|
|
|
42
51
|
def partial_rerun? = @partial_rerun
|
|
52
|
+
def authoritative? = @authoritative
|
|
43
53
|
|
|
44
54
|
# @return [Integer] number of killed mutants
|
|
45
55
|
def killed = mutants.count(&:killed?)
|
|
@@ -53,7 +63,7 @@ module Henitai
|
|
|
53
63
|
# Detected = killed + timeout + runtime_error (alle Zustände die einen Fehler beweisen)
|
|
54
64
|
# @return [Integer]
|
|
55
65
|
def detected
|
|
56
|
-
mutants
|
|
66
|
+
detected_in(mutants)
|
|
57
67
|
end
|
|
58
68
|
|
|
59
69
|
# Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
|
|
@@ -66,11 +76,7 @@ module Henitai
|
|
|
66
76
|
#
|
|
67
77
|
# @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
|
|
68
78
|
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
|
|
79
|
+
mutation_score_for(mutants)
|
|
74
80
|
end
|
|
75
81
|
|
|
76
82
|
# Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
|
|
@@ -82,9 +88,23 @@ module Henitai
|
|
|
82
88
|
#
|
|
83
89
|
# @return [Float, nil]
|
|
84
90
|
def mutation_score_indicator
|
|
85
|
-
|
|
91
|
+
mutation_score_indicator_for(mutants)
|
|
92
|
+
end
|
|
86
93
|
|
|
87
|
-
|
|
94
|
+
# Scores over only the mutants actually executed this run — verdicts
|
|
95
|
+
# reused from the history store (`--incremental`) excluded. nil when
|
|
96
|
+
# nothing was reused: the combined scores already tell the whole story,
|
|
97
|
+
# and reporters use nil to suppress the extra line.
|
|
98
|
+
#
|
|
99
|
+
# @return [Hash, nil] { mutation_score:, mutation_score_indicator: }
|
|
100
|
+
def executed_scoring_summary
|
|
101
|
+
executed = mutants.reject { |m| m.respond_to?(:from_cache?) && m.from_cache? }
|
|
102
|
+
return nil if executed.size == mutants.size
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
mutation_score: mutation_score_for(executed),
|
|
106
|
+
mutation_score_indicator: mutation_score_indicator_for(executed)
|
|
107
|
+
}
|
|
88
108
|
end
|
|
89
109
|
|
|
90
110
|
# Compact public summary for reporters.
|
|
@@ -119,6 +139,24 @@ module Henitai
|
|
|
119
139
|
|
|
120
140
|
private
|
|
121
141
|
|
|
142
|
+
def detected_in(list)
|
|
143
|
+
list.count { |m| %i[killed timeout runtime_error].include?(m.status) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def mutation_score_for(list)
|
|
147
|
+
excluded = %i[ignored no_coverage compile_error equivalent]
|
|
148
|
+
valid = list.reject { |m| excluded.include?(m.status) }
|
|
149
|
+
return nil if valid.empty?
|
|
150
|
+
|
|
151
|
+
((detected_in(list).to_f / valid.count) * 100.0).round(2).to_f
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def mutation_score_indicator_for(list)
|
|
155
|
+
return nil if list.empty?
|
|
156
|
+
|
|
157
|
+
((list.count(&:killed?).to_f / list.count) * 100.0).round(2).to_f
|
|
158
|
+
end
|
|
159
|
+
|
|
122
160
|
def base_schema
|
|
123
161
|
{ # : Hash[Symbol, untyped]
|
|
124
162
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -148,10 +186,13 @@ module Henitai
|
|
|
148
186
|
{
|
|
149
187
|
id: mutant.id,
|
|
150
188
|
stableId: mutant.stable_id,
|
|
189
|
+
legacyStableId: legacy_stable_id_for(mutant),
|
|
151
190
|
mutatorName: mutant.operator,
|
|
152
191
|
replacement: replacement_for(mutant),
|
|
153
192
|
location: location_for(mutant),
|
|
154
193
|
status: stryker_status(mutant.status),
|
|
194
|
+
statusReason: status_reason_for(mutant),
|
|
195
|
+
fromCache: from_cache_for(mutant),
|
|
155
196
|
description: mutant.description,
|
|
156
197
|
duration: duration_for(mutant)
|
|
157
198
|
}.compact.merge(coverage_schema(mutant))
|
|
@@ -168,6 +209,8 @@ module Henitai
|
|
|
168
209
|
end
|
|
169
210
|
|
|
170
211
|
def replacement_for(mutant)
|
|
212
|
+
return mutant.mutated_source if mutant.respond_to?(:mutated_source)
|
|
213
|
+
|
|
171
214
|
safe_unparse(mutant.mutated_node)
|
|
172
215
|
end
|
|
173
216
|
|
|
@@ -190,6 +233,28 @@ module Henitai
|
|
|
190
233
|
mutant.duration&.then { |d| (d * 1000).round }
|
|
191
234
|
end
|
|
192
235
|
|
|
236
|
+
# Serialized as the schema's statusReason so directive reasons show up
|
|
237
|
+
# next to the Ignored status in the HTML report.
|
|
238
|
+
def status_reason_for(mutant)
|
|
239
|
+
return nil unless mutant.respond_to?(:ignore_reason)
|
|
240
|
+
|
|
241
|
+
mutant.ignore_reason
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Vendored extension field (like stableId): marks verdicts reused from
|
|
245
|
+
# the history store so reports stay honest about what actually executed.
|
|
246
|
+
def from_cache_for(mutant)
|
|
247
|
+
return nil unless mutant.respond_to?(:from_cache?)
|
|
248
|
+
|
|
249
|
+
mutant.from_cache? || nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def legacy_stable_id_for(mutant)
|
|
253
|
+
return if mutant.respond_to?(:precomputed_stable_id) && mutant.precomputed_stable_id
|
|
254
|
+
|
|
255
|
+
MutantIdentity.legacy_stable_id(mutant)
|
|
256
|
+
end
|
|
257
|
+
|
|
193
258
|
def equivalence_uncertainty
|
|
194
259
|
return nil if mutation_score.nil?
|
|
195
260
|
|
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,10 @@ 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?,
|
|
172
|
+
since: @since
|
|
129
173
|
)
|
|
130
|
-
persist_history(@result, finished_at)
|
|
131
|
-
report(@result)
|
|
132
|
-
@result
|
|
133
174
|
end
|
|
134
175
|
|
|
135
176
|
# Reads each source file once and caches it, so Result consumes source
|
|
@@ -158,29 +199,17 @@ module Henitai
|
|
|
158
199
|
coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
|
|
159
200
|
end
|
|
160
201
|
|
|
161
|
-
def subject_resolver
|
|
162
|
-
@subject_resolver ||= SubjectResolver.new
|
|
163
|
-
end
|
|
202
|
+
def subject_resolver = @subject_resolver ||= SubjectResolver.new
|
|
164
203
|
|
|
165
|
-
def git_diff_analyzer
|
|
166
|
-
@git_diff_analyzer ||= GitDiffAnalyzer.new
|
|
167
|
-
end
|
|
204
|
+
def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
|
|
168
205
|
|
|
169
|
-
def mutant_generator
|
|
170
|
-
@mutant_generator ||= MutantGenerator.new
|
|
171
|
-
end
|
|
206
|
+
def mutant_generator = @mutant_generator ||= MutantGenerator.new
|
|
172
207
|
|
|
173
|
-
def static_filter
|
|
174
|
-
@static_filter ||= StaticFilter.new
|
|
175
|
-
end
|
|
208
|
+
def static_filter = @static_filter ||= StaticFilter.new
|
|
176
209
|
|
|
177
|
-
def execution_engine
|
|
178
|
-
@execution_engine ||= ExecutionEngine.new
|
|
179
|
-
end
|
|
210
|
+
def execution_engine = @execution_engine ||= ExecutionEngine.new
|
|
180
211
|
|
|
181
|
-
def coverage_bootstrapper
|
|
182
|
-
@coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
183
|
-
end
|
|
212
|
+
def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
184
213
|
|
|
185
214
|
def integration
|
|
186
215
|
@integration ||= Integration.for(config.integration).new
|
|
@@ -190,18 +219,26 @@ module Henitai
|
|
|
190
219
|
@operators ||= Operator.for_set(config.operators)
|
|
191
220
|
end
|
|
192
221
|
|
|
222
|
+
# Fans progress out to the terminal reporter (when enabled) and the
|
|
223
|
+
# checkpoint writer (when enabled and a file report is configured), so a
|
|
224
|
+
# long run persists partial results incrementally.
|
|
193
225
|
def progress_reporter
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
Reporter::Terminal.new(config:)
|
|
226
|
+
CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?)
|
|
197
227
|
end
|
|
198
228
|
|
|
199
229
|
def history_store
|
|
200
230
|
@history_store ||= MutantHistoryStore.new(
|
|
201
|
-
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
|
|
231
|
+
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage:
|
|
202
232
|
)
|
|
203
233
|
end
|
|
204
234
|
|
|
235
|
+
# One shared live view of the per-test coverage map: the incremental
|
|
236
|
+
# filter proves survivor reuse against it and the history store records
|
|
237
|
+
# the same intersection set — one implementation, one snapshot.
|
|
238
|
+
def per_test_coverage
|
|
239
|
+
@per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir)
|
|
240
|
+
end
|
|
241
|
+
|
|
205
242
|
def source_files
|
|
206
243
|
@source_files ||= filter_changed(reject_excluded(included_source_files))
|
|
207
244
|
end
|
|
@@ -231,12 +268,28 @@ module Henitai
|
|
|
231
268
|
def filter_changed(files)
|
|
232
269
|
return files unless @since
|
|
233
270
|
|
|
234
|
-
changed_file_set =
|
|
235
|
-
|
|
236
|
-
.map { |path| normalize_path(path) }
|
|
271
|
+
changed_file_set = changed_paths_since.map { |path| normalize_path(path) }
|
|
272
|
+
changed_file_set += covered_sources_for_changed_tests(changed_file_set)
|
|
237
273
|
files.select { |path| changed_file_set.include?(normalize_path(path)) }
|
|
238
274
|
end
|
|
239
275
|
|
|
276
|
+
# Committed changes since the ref plus the current working tree (tracked
|
|
277
|
+
# dirty and untracked files): the working tree is what gets tested, so it
|
|
278
|
+
# is always part of "changed since REF".
|
|
279
|
+
def changed_paths_since
|
|
280
|
+
git_diff_analyzer.changed_files(from: @since, to: "HEAD") +
|
|
281
|
+
git_diff_analyzer.working_tree_changed_files
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Changed test files select the source files they cover, so an edited
|
|
285
|
+
# test re-tests the subjects it can kill. Uses the per-test map from the
|
|
286
|
+
# previous run — Gate 1 runs before this run's bootstrap finishes.
|
|
287
|
+
def covered_sources_for_changed_tests(changed_paths)
|
|
288
|
+
changed_paths
|
|
289
|
+
.flat_map { |path| per_test_coverage.source_files_covered_by(path) }
|
|
290
|
+
.map { |path| normalize_path(path) }
|
|
291
|
+
end
|
|
292
|
+
|
|
240
293
|
def pattern_subjects
|
|
241
294
|
Array(@subjects)
|
|
242
295
|
end
|
|
@@ -259,6 +312,12 @@ module Henitai
|
|
|
259
312
|
!@survivors_from.nil?
|
|
260
313
|
end
|
|
261
314
|
|
|
315
|
+
# Mutation-scope full run, controlling Result#authoritative? — distinct
|
|
316
|
+
# from the per-test-coverage plan's test-suite-scope "full run".
|
|
317
|
+
def full_run?
|
|
318
|
+
pattern_subjects.empty? && @since.nil? && !survivor_rerun?
|
|
319
|
+
end
|
|
320
|
+
|
|
262
321
|
def survivor_strategy
|
|
263
322
|
@survivor_strategy ||= SurvivorRerunStrategy.new(
|
|
264
323
|
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
|