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
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "verdict_fingerprint"
|
|
7
|
+
|
|
3
8
|
module Henitai
|
|
4
9
|
# Ensures coverage data exists before the mutation pipeline starts.
|
|
5
10
|
class CoverageBootstrapper
|
|
11
|
+
# Sidecar recording which dependency files existed when the coverage
|
|
12
|
+
# artifacts were produced. Deletions drop a path from the current set but
|
|
13
|
+
# leave every surviving file's mtime untouched, so freshness must compare
|
|
14
|
+
# the recorded path set, not just watch existing files.
|
|
15
|
+
DEPENDENCY_MANIFEST_FILE = "henitai_dependency_manifest.json"
|
|
16
|
+
|
|
6
17
|
def initialize(static_filter: StaticFilter.new)
|
|
7
18
|
@static_filter = static_filter
|
|
8
19
|
end
|
|
9
20
|
|
|
21
|
+
# Writes the current dependency path set next to the coverage artifacts.
|
|
22
|
+
# Called after every bootstrap; exposed so tests can seed a manifest.
|
|
23
|
+
def record_dependency_manifest(config)
|
|
24
|
+
FileUtils.mkdir_p(reports_dir(config))
|
|
25
|
+
File.write(dependency_manifest_path(config), JSON.generate(current_dependency_paths))
|
|
26
|
+
end
|
|
27
|
+
|
|
10
28
|
# Runs the test suite to collect coverage, unless a fresh report already
|
|
11
29
|
# exists.
|
|
12
30
|
#
|
|
@@ -62,14 +80,38 @@ module Henitai
|
|
|
62
80
|
Array(source_files).map { |path| File.expand_path(path) }
|
|
63
81
|
end
|
|
64
82
|
|
|
65
|
-
# Returns true when a coverage report already exists
|
|
66
|
-
#
|
|
83
|
+
# Returns true when a coverage report already exists, is newer than every
|
|
84
|
+
# watched source and test file, and the dependency path set is unchanged
|
|
85
|
+
# since the report was produced. Stale or absent reports return false.
|
|
67
86
|
def coverage_fresh?(source_files, config, test_files)
|
|
68
87
|
watched_files_fresh?(
|
|
69
88
|
coverage_report_path(config),
|
|
70
89
|
source_files,
|
|
71
90
|
test_files
|
|
72
|
-
)
|
|
91
|
+
) && dependency_manifest_current?(config)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# False when the manifest is missing, unreadable, or lists a different
|
|
95
|
+
# path set than the files currently on disk — conservative: a deleted
|
|
96
|
+
# dependency invalidates the coverage artifacts just like an edit.
|
|
97
|
+
def dependency_manifest_current?(config)
|
|
98
|
+
recorded = JSON.parse(File.read(dependency_manifest_path(config)))
|
|
99
|
+
recorded == current_dependency_paths
|
|
100
|
+
rescue StandardError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def dependency_manifest_path(config)
|
|
105
|
+
File.join(reports_dir(config), DEPENDENCY_MANIFEST_FILE)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Paths stored relative to the working directory so the manifest survives
|
|
109
|
+
# a repository move.
|
|
110
|
+
def current_dependency_paths
|
|
111
|
+
root = Dir.pwd
|
|
112
|
+
VerdictFingerprint.dependency_files(root).map do |path|
|
|
113
|
+
path.delete_prefix("#{root}#{File::SEPARATOR}")
|
|
114
|
+
end
|
|
73
115
|
end
|
|
74
116
|
|
|
75
117
|
def coverage_report_path(config)
|
|
@@ -86,7 +128,10 @@ module Henitai
|
|
|
86
128
|
with_reports_dir(config) do
|
|
87
129
|
with_coverage_dir(config) do
|
|
88
130
|
result = integration.run_suite(test_files)
|
|
89
|
-
|
|
131
|
+
if result == :survived
|
|
132
|
+
record_dependency_manifest(config)
|
|
133
|
+
return
|
|
134
|
+
end
|
|
90
135
|
|
|
91
136
|
raise CoverageError, build_bootstrap_error(result)
|
|
92
137
|
end
|
|
@@ -172,8 +217,12 @@ module Henitai
|
|
|
172
217
|
end
|
|
173
218
|
end
|
|
174
219
|
|
|
220
|
+
# Dependency files (helpers, support, fixtures, lockfile, tool config)
|
|
221
|
+
# are watched alongside sources and tests: they shape which tests cover
|
|
222
|
+
# what, so a stale per-test map after a dependency edit would let the
|
|
223
|
+
# test selector omit a newly covering test (ADR-11).
|
|
175
224
|
def watched_files(source_files, test_files)
|
|
176
|
-
Array(source_files) + Array(test_files)
|
|
225
|
+
Array(source_files) + Array(test_files) + VerdictFingerprint.dependency_files
|
|
177
226
|
end
|
|
178
227
|
|
|
179
228
|
def resolve_test_files(integration, test_files)
|
|
@@ -13,7 +13,11 @@ module Henitai
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def example_finished(notification)
|
|
16
|
-
|
|
16
|
+
example = notification.example
|
|
17
|
+
@collector.record_test(
|
|
18
|
+
example.metadata[:file_path],
|
|
19
|
+
duration: example.execution_result.run_time
|
|
20
|
+
)
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def dump_summary(_summary)
|
|
@@ -22,17 +22,40 @@ module Henitai
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def test_lines_by_file(path = DEFAULT_PER_TEST_COVERAGE_REPORT_PATH)
|
|
25
|
+
per_test_report(path).transform_values do |entry|
|
|
26
|
+
normalize_test_coverage(unwrap_coverage(entry))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Wall-clock seconds per test file, from reports whose entries carry a
|
|
31
|
+
# "duration" field. Legacy reports (plain source-map entries) yield {}.
|
|
32
|
+
def durations_by_test(path = DEFAULT_PER_TEST_COVERAGE_REPORT_PATH)
|
|
33
|
+
per_test_report(path).filter_map do |test_file, entry|
|
|
34
|
+
next unless wrapped_entry?(entry) && entry.key?("duration")
|
|
35
|
+
|
|
36
|
+
[test_file, entry.fetch("duration").to_f]
|
|
37
|
+
end.to_h
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def per_test_report(path)
|
|
25
43
|
return {} unless File.exist?(path)
|
|
26
44
|
|
|
27
45
|
parsed = JSON.parse(File.read(path))
|
|
28
|
-
|
|
46
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
47
|
+
end
|
|
29
48
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
# New-format entries wrap the source map under "coverage" (with a sibling
|
|
50
|
+
# "duration"); legacy entries are the source map itself. Source-map keys
|
|
51
|
+
# are absolute file paths, so a plain "coverage" key is unambiguous.
|
|
52
|
+
def wrapped_entry?(entry)
|
|
53
|
+
entry.is_a?(Hash) && entry["coverage"].is_a?(Hash)
|
|
33
54
|
end
|
|
34
55
|
|
|
35
|
-
|
|
56
|
+
def unwrap_coverage(entry)
|
|
57
|
+
wrapped_entry?(entry) ? entry.fetch("coverage") : entry
|
|
58
|
+
end
|
|
36
59
|
|
|
37
60
|
def covered_lines(file_coverage)
|
|
38
61
|
Array(file_coverage["lines"]).each_with_index.filter_map do |count, index|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class ExecutionEngine
|
|
5
|
+
# Run-scoped ENV/working-directory setup for {ExecutionEngine}: exposes the
|
|
6
|
+
# reports dir, mutation-coverage dir, per-stream log cap and worker-slot
|
|
7
|
+
# index to forked children via ENV, restoring the prior values afterwards.
|
|
8
|
+
# Extracted so the engine class stays focused on execution scheduling.
|
|
9
|
+
module EnvScope
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def with_reports_dir(config)
|
|
13
|
+
original = ENV.fetch("HENITAI_REPORTS_DIR", nil)
|
|
14
|
+
ENV["HENITAI_REPORTS_DIR"] = config.reports_dir
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
restore_env("HENITAI_REPORTS_DIR", original)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_coverage_dir(config)
|
|
21
|
+
original = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
|
|
22
|
+
ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(config)
|
|
23
|
+
yield
|
|
24
|
+
ensure
|
|
25
|
+
restore_env("HENITAI_COVERAGE_DIR", original)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_max_log_bytes(config)
|
|
29
|
+
cap = config.respond_to?(:max_log_bytes) ? config.max_log_bytes : nil
|
|
30
|
+
return yield if cap.nil?
|
|
31
|
+
|
|
32
|
+
env_key = Integration::ScenarioLogSupport::MAX_LOG_BYTES_ENV
|
|
33
|
+
original = ENV.fetch(env_key, nil)
|
|
34
|
+
ENV[env_key] = cap.to_s
|
|
35
|
+
yield
|
|
36
|
+
ensure
|
|
37
|
+
restore_env(env_key, original) if cap
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Linear-path children inherit slot 0 so suite-side isolation code works
|
|
41
|
+
# identically in both execution modes; the parallel scheduler overwrites
|
|
42
|
+
# the value per spawn. Restored afterwards like the other run-scoped vars.
|
|
43
|
+
def with_worker_slot
|
|
44
|
+
original = ENV.fetch(SlotScheduler::WORKER_SLOT_ENV, nil)
|
|
45
|
+
ENV[SlotScheduler::WORKER_SLOT_ENV] = "0"
|
|
46
|
+
yield
|
|
47
|
+
ensure
|
|
48
|
+
restore_env(SlotScheduler::WORKER_SLOT_ENV, original)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def restore_env(key, original)
|
|
52
|
+
if original.nil?
|
|
53
|
+
ENV.delete(key)
|
|
54
|
+
else
|
|
55
|
+
ENV[key] = original
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def mutation_coverage_dir(config)
|
|
60
|
+
base_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
|
|
61
|
+
base_dir = "reports" if base_dir.nil? || base_dir.empty?
|
|
62
|
+
|
|
63
|
+
File.join(base_dir, "mutation-coverage")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "parallel_execution_runner"
|
|
4
3
|
require_relative "process_worker_runner"
|
|
4
|
+
require_relative "execution_engine/env_scope"
|
|
5
5
|
|
|
6
6
|
module Henitai
|
|
7
7
|
# Runs pending mutants through the selected integration.
|
|
8
8
|
class ExecutionEngine
|
|
9
|
+
include EnvScope
|
|
10
|
+
|
|
9
11
|
def run(mutants, integration, config, progress_reporter: nil)
|
|
10
12
|
with_reports_dir(config) do
|
|
11
13
|
with_coverage_dir(config) do
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
run_parallel(pending_mutants, integration, config, progress_reporter)
|
|
17
|
-
else
|
|
18
|
-
run_linear(pending_mutants, integration, config, progress_reporter, mutex)
|
|
14
|
+
with_max_log_bytes(config) do
|
|
15
|
+
with_worker_slot do
|
|
16
|
+
execute(mutants, integration, config, progress_reporter)
|
|
17
|
+
end
|
|
19
18
|
end
|
|
20
|
-
|
|
21
|
-
warn_flaky_mutants(pending_mutants.size)
|
|
22
|
-
mutants
|
|
23
19
|
end
|
|
24
20
|
end
|
|
25
21
|
end
|
|
26
22
|
|
|
27
23
|
private
|
|
28
24
|
|
|
25
|
+
def execute(mutants, integration, config, progress_reporter)
|
|
26
|
+
@flaky_retry_count = 0
|
|
27
|
+
pending_mutants = Array(mutants).select(&:pending?)
|
|
28
|
+
mutex = Mutex.new
|
|
29
|
+
if parallel_execution?(config, pending_mutants)
|
|
30
|
+
run_parallel(pending_mutants, integration, config, progress_reporter)
|
|
31
|
+
else
|
|
32
|
+
run_linear(pending_mutants, integration, config, progress_reporter, mutex)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
warn_flaky_mutants(pending_mutants.size)
|
|
36
|
+
mutants
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
def parallel_execution?(config, mutants)
|
|
30
40
|
worker_count(config) > 1 && mutants.size > 1
|
|
31
41
|
end
|
|
@@ -53,7 +63,8 @@ module Henitai
|
|
|
53
63
|
integration,
|
|
54
64
|
config,
|
|
55
65
|
progress_reporter,
|
|
56
|
-
test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) }
|
|
66
|
+
test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) },
|
|
67
|
+
timeout_resolver: ->(_mutant, test_files) { resolved_timeout(test_files, config) }
|
|
57
68
|
)
|
|
58
69
|
@flaky_retry_count = runner.flaky_retry_count
|
|
59
70
|
results
|
|
@@ -61,11 +72,26 @@ module Henitai
|
|
|
61
72
|
|
|
62
73
|
def process_mutant(mutant, integration, config, progress_reporter, mutex)
|
|
63
74
|
test_files = prioritized_tests_for(mutant, integration, config)
|
|
64
|
-
mutant
|
|
65
|
-
mutant
|
|
75
|
+
record_test_files(mutant, test_files)
|
|
76
|
+
return record_no_coverage(mutant, progress_reporter, mutex) if test_files.empty?
|
|
77
|
+
|
|
66
78
|
scenario_result = run_with_flaky_retry(mutant, integration, config, test_files, mutex)
|
|
67
79
|
mutant.status = scenario_status(scenario_result)
|
|
68
80
|
|
|
81
|
+
report_progress(mutant, scenario_result, progress_reporter, mutex)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def record_test_files(mutant, test_files)
|
|
85
|
+
mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
|
|
86
|
+
mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def record_no_coverage(mutant, progress_reporter, mutex)
|
|
90
|
+
mutant.status = :no_coverage
|
|
91
|
+
report_progress(mutant, nil, progress_reporter, mutex)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def report_progress(mutant, scenario_result, progress_reporter, mutex)
|
|
69
95
|
if mutex
|
|
70
96
|
mutex.synchronize { progress_reporter&.progress(mutant, scenario_result:) }
|
|
71
97
|
else
|
|
@@ -74,16 +100,81 @@ module Henitai
|
|
|
74
100
|
end
|
|
75
101
|
|
|
76
102
|
def prioritized_tests_for(mutant, integration, config)
|
|
77
|
-
tests = integration.select_tests(mutant.subject)
|
|
103
|
+
tests = reject_excluded_tests(integration.select_tests(mutant.subject), config)
|
|
78
104
|
tests = per_test_coverage_selector.filter(
|
|
79
105
|
tests,
|
|
80
106
|
mutant,
|
|
81
107
|
reports_dir: config.reports_dir
|
|
82
108
|
)
|
|
83
|
-
test_prioritizer.sort(tests, mutant, test_history(config))
|
|
109
|
+
test_prioritizer(config).sort(tests, mutant, test_history(config))
|
|
84
110
|
end
|
|
85
111
|
|
|
86
|
-
|
|
112
|
+
# Drops test files matching any config.test_excludes glob. Used to keep a
|
|
113
|
+
# mutant child from re-running tests that themselves spawn henitai/forked
|
|
114
|
+
# subprocesses (e.g. the CLI and process-scheduler specs when dogfooding
|
|
115
|
+
# henitai on itself), which otherwise multiplies processes and log noise.
|
|
116
|
+
def reject_excluded_tests(tests, config)
|
|
117
|
+
patterns = config.respond_to?(:test_excludes) ? Array(config.test_excludes) : []
|
|
118
|
+
return tests if patterns.empty?
|
|
119
|
+
|
|
120
|
+
expanded = patterns.map { |pattern| File.expand_path(pattern) }
|
|
121
|
+
tests.reject do |path|
|
|
122
|
+
candidate = File.expand_path(path)
|
|
123
|
+
expanded.any? { |pattern| File.fnmatch?(pattern, candidate, File::FNM_PATHNAME) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_prioritizer(config)
|
|
128
|
+
@test_prioritizer ||= TestPrioritizer.new(timing_source: timing_source(config))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def timing_source(config)
|
|
132
|
+
path = File.join(config.reports_dir, PerTestCoverageCollector::REPORT_FILE_NAME)
|
|
133
|
+
-> { CoverageReportReader.new.durations_by_test(path) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Fixed `mutation.timeout` wins untouched; when unset, the timeout is
|
|
137
|
+
# calibrated per mutant from its selected tests' measured durations, with
|
|
138
|
+
# a single warning per run when no timing data is available.
|
|
139
|
+
def resolved_timeout(test_files, config)
|
|
140
|
+
return config.timeout unless calibration_enabled?(config)
|
|
141
|
+
|
|
142
|
+
calibrated = timeout_calibrator(config).timeout_for(test_files)
|
|
143
|
+
return [calibrated, max_timeout(config)].min if calibrated
|
|
144
|
+
|
|
145
|
+
warn_timeout_fallback_once
|
|
146
|
+
Configuration::DEFAULT_TIMEOUT
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Ceiling on the auto-calibrated timeout so a runaway mutant is killed in
|
|
150
|
+
# seconds instead of running for minutes when the calibrated value (derived
|
|
151
|
+
# from a slow baseline) is large. A fixed mutation.timeout bypasses this.
|
|
152
|
+
def max_timeout(config)
|
|
153
|
+
return config.max_timeout if config.respond_to?(:max_timeout) && config.max_timeout
|
|
154
|
+
|
|
155
|
+
Configuration::DEFAULT_MAX_TIMEOUT
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def calibration_enabled?(config)
|
|
159
|
+
config.respond_to?(:timeout_configured?) && !config.timeout_configured?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def timeout_calibrator(config)
|
|
163
|
+
@timeout_calibrator ||= TimeoutCalibrator.new(
|
|
164
|
+
timing_source: timing_source(config),
|
|
165
|
+
multiplier: config.timeout_multiplier
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def warn_timeout_fallback_once
|
|
170
|
+
return if @warned_timeout_fallback
|
|
171
|
+
|
|
172
|
+
@warned_timeout_fallback = true
|
|
173
|
+
warn(
|
|
174
|
+
"Timeout calibration unavailable (no per-test timing data); " \
|
|
175
|
+
"falling back to the default timeout of #{Configuration::DEFAULT_TIMEOUT}s"
|
|
176
|
+
)
|
|
177
|
+
end
|
|
87
178
|
|
|
88
179
|
def per_test_coverage_selector = @per_test_coverage_selector ||= PerTestCoverageSelector.new
|
|
89
180
|
|
|
@@ -98,10 +189,11 @@ module Henitai
|
|
|
98
189
|
# runtime on real CI workloads.
|
|
99
190
|
# rubocop:disable Metrics/MethodLength
|
|
100
191
|
def run_with_flaky_retry(mutant, integration, config, test_files, mutex)
|
|
192
|
+
timeout = resolved_timeout(test_files, config)
|
|
101
193
|
scenario_result = integration.run_mutant(
|
|
102
194
|
mutant:,
|
|
103
195
|
test_files:,
|
|
104
|
-
timeout:
|
|
196
|
+
timeout: timeout
|
|
105
197
|
)
|
|
106
198
|
return scenario_result unless scenario_status(scenario_result) == :survived
|
|
107
199
|
|
|
@@ -111,7 +203,7 @@ module Henitai
|
|
|
111
203
|
scenario_result = integration.run_mutant(
|
|
112
204
|
mutant:,
|
|
113
205
|
test_files:,
|
|
114
|
-
timeout:
|
|
206
|
+
timeout: timeout
|
|
115
207
|
)
|
|
116
208
|
break unless scenario_status(scenario_result) == :survived
|
|
117
209
|
end
|
|
@@ -141,37 +233,6 @@ module Henitai
|
|
|
141
233
|
)
|
|
142
234
|
end
|
|
143
235
|
|
|
144
|
-
def with_reports_dir(config)
|
|
145
|
-
original_reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", nil)
|
|
146
|
-
ENV["HENITAI_REPORTS_DIR"] = config.reports_dir
|
|
147
|
-
yield
|
|
148
|
-
ensure
|
|
149
|
-
if original_reports_dir.nil?
|
|
150
|
-
ENV.delete("HENITAI_REPORTS_DIR")
|
|
151
|
-
else
|
|
152
|
-
ENV["HENITAI_REPORTS_DIR"] = original_reports_dir
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def with_coverage_dir(config)
|
|
157
|
-
original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
|
|
158
|
-
ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(config)
|
|
159
|
-
yield
|
|
160
|
-
ensure
|
|
161
|
-
if original_coverage_dir.nil?
|
|
162
|
-
ENV.delete("HENITAI_COVERAGE_DIR")
|
|
163
|
-
else
|
|
164
|
-
ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def mutation_coverage_dir(config)
|
|
169
|
-
base_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
|
|
170
|
-
base_dir = "reports" if base_dir.nil? || base_dir.empty?
|
|
171
|
-
|
|
172
|
-
File.join(base_dir, "mutation-coverage")
|
|
173
|
-
end
|
|
174
|
-
|
|
175
236
|
def max_flaky_retries(config)
|
|
176
237
|
return 3 unless config.respond_to?(:max_flaky_retries)
|
|
177
238
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Recognizes directories holding henitai/SimpleCov *output* so dependency
|
|
5
|
+
# scans can skip them. Detection requires artifact evidence — known output
|
|
6
|
+
# files inside the directory — not just a conventional name: a directory
|
|
7
|
+
# merely named reports/ or coverage/ may hold legitimate fixture input,
|
|
8
|
+
# and wrongly excluding a real dependency would let an obsolete Survived
|
|
9
|
+
# verdict be reused (unsound). Wrongly including junk merely re-runs.
|
|
10
|
+
module GeneratedArtifacts
|
|
11
|
+
# Evidence that a coverage/ directory is SimpleCov output.
|
|
12
|
+
COVERAGE_MARKERS = %w[.resultset.json .last_run.json].freeze
|
|
13
|
+
|
|
14
|
+
# Evidence that a reports/ directory is a henitai reports_dir.
|
|
15
|
+
REPORT_MARKERS = %w[
|
|
16
|
+
mutation-report.json
|
|
17
|
+
mutation-report.html
|
|
18
|
+
mutation-history.sqlite3
|
|
19
|
+
mutation-history.json
|
|
20
|
+
henitai_per_test.json
|
|
21
|
+
henitai_dependency_manifest.json
|
|
22
|
+
.henitai-run.lock
|
|
23
|
+
mutation-logs
|
|
24
|
+
mutation-coverage
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# True when the directory carries a generated-artifact name AND contains
|
|
30
|
+
# evidence of actual output. Unreadable directories count as evidence-
|
|
31
|
+
# free — they stay in the scan (conservative direction).
|
|
32
|
+
def generated_dir?(path)
|
|
33
|
+
case File.basename(path)
|
|
34
|
+
when "coverage" then evidence?(path, COVERAGE_MARKERS)
|
|
35
|
+
when "reports" then evidence?(path, REPORT_MARKERS)
|
|
36
|
+
when "mutation-logs", "mutation-coverage" then per_mutant_evidence?(path)
|
|
37
|
+
else false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Dir.each_child returns an Enumerator, which has no #intersect? —
|
|
42
|
+
# Style/ArrayIntersect's autocorrect would produce a NoMethodError here.
|
|
43
|
+
def evidence?(dir, markers)
|
|
44
|
+
Dir.each_child(dir).any? { |entry| markers.include?(entry) } # rubocop:disable Style/ArrayIntersect
|
|
45
|
+
rescue SystemCallError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Per-mutant scratch trees hold one entry per mutant (or baseline);
|
|
50
|
+
# checked lazily — these directories can contain tens of thousands of
|
|
51
|
+
# files, so never materialize the full listing.
|
|
52
|
+
def per_mutant_evidence?(dir)
|
|
53
|
+
Dir.each_child(dir).any? { |entry| entry.start_with?("mutant-", "baseline") }
|
|
54
|
+
rescue SystemCallError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "verdict_fingerprint"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Marks mutants whose verdict from a prior run is still valid so
|
|
7
|
+
# `--incremental` runs skip re-executing them (Gate 3.5, opt-in).
|
|
8
|
+
#
|
|
9
|
+
# Reuse rules (conservative — any doubt resolves to re-execution):
|
|
10
|
+
#
|
|
11
|
+
# * Killed: the stored verdict must carry both fingerprints, and both the
|
|
12
|
+
# subject's source and every recorded covering test file must be
|
|
13
|
+
# byte-identical to what was recorded. Killed is monotone, so no live
|
|
14
|
+
# coverage is needed.
|
|
15
|
+
# * Survived: additionally the LIVE covering set (the full-map
|
|
16
|
+
# intersection computed via {PerTestCoverage}) must equal the recorded
|
|
17
|
+
# set in membership, and the run-level dependency fingerprint must be
|
|
18
|
+
# unchanged — a new or edited test reaching the mutant always forces a
|
|
19
|
+
# re-run (ADR-11). Without a live per-test map or dependency
|
|
20
|
+
# fingerprint, survivors always re-execute.
|
|
21
|
+
#
|
|
22
|
+
# Timeouts, errors, unknown ids and legacy rows always re-execute.
|
|
23
|
+
class IncrementalFilter
|
|
24
|
+
def initialize(history_store:, per_test_coverage: nil, dependency_fingerprint: nil)
|
|
25
|
+
@history_store = history_store
|
|
26
|
+
@per_test_coverage = per_test_coverage
|
|
27
|
+
@dependency_fingerprint = dependency_fingerprint
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array<Mutant>] the same collection; cache hits get the stored
|
|
31
|
+
# status (:killed or :survived) and from_cache = true.
|
|
32
|
+
def apply(mutants)
|
|
33
|
+
collection = Array(mutants)
|
|
34
|
+
ambiguous = ambiguous_stable_ids(collection)
|
|
35
|
+
collection.each do |mutant|
|
|
36
|
+
next unless mutant.pending?
|
|
37
|
+
next if ambiguous.include?(mutant.stable_id)
|
|
38
|
+
|
|
39
|
+
status = reusable_status(mutant)
|
|
40
|
+
next unless status
|
|
41
|
+
|
|
42
|
+
mutant.status = status
|
|
43
|
+
mutant.from_cache = true if mutant.respond_to?(:from_cache=)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
mutants
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :history_store, :per_test_coverage, :dependency_fingerprint
|
|
52
|
+
|
|
53
|
+
# MutantIdentity deliberately omits source coordinates (line-drift
|
|
54
|
+
# tolerance), so distinct mutants inside one subject can share a stable
|
|
55
|
+
# id. A shared id makes the stored verdict ambiguous — one colliding
|
|
56
|
+
# mutant may have been killed while another errored — so reuse is skipped
|
|
57
|
+
# for every mutant whose id appears more than once in this run.
|
|
58
|
+
def ambiguous_stable_ids(mutants)
|
|
59
|
+
mutants.group_by(&:stable_id).filter_map do |stable_id, group|
|
|
60
|
+
stable_id if group.size > 1
|
|
61
|
+
end.to_set
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reusable_status(mutant)
|
|
65
|
+
verdict = history_store.verdict_for(mutant.stable_id)
|
|
66
|
+
return nil unless verdict
|
|
67
|
+
|
|
68
|
+
case verdict.fetch(:status)
|
|
69
|
+
when :killed
|
|
70
|
+
:killed if killed_reusable?(mutant, verdict)
|
|
71
|
+
when :survived
|
|
72
|
+
:survived if survived_reusable?(mutant, verdict)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def killed_reusable?(mutant, verdict)
|
|
77
|
+
subject_source_unchanged?(mutant, verdict) &&
|
|
78
|
+
VerdictFingerprint.tests_fingerprint_current?(verdict.fetch(:covered_tests_fingerprint))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The filter checks per-test map availability itself instead of trusting
|
|
82
|
+
# the bootstrap's readiness skip: integrations without per-test support
|
|
83
|
+
# or a missing/empty map mean the live covering set is unknowable, and
|
|
84
|
+
# unknowable means re-execute.
|
|
85
|
+
def survived_reusable?(mutant, verdict)
|
|
86
|
+
return false unless per_test_coverage&.available?
|
|
87
|
+
return false unless subject_source_unchanged?(mutant, verdict)
|
|
88
|
+
|
|
89
|
+
VerdictFingerprint.survivor_fingerprint_current?(
|
|
90
|
+
verdict.fetch(:covered_tests_fingerprint),
|
|
91
|
+
live_paths: per_test_coverage.tests_covering(mutant),
|
|
92
|
+
dependency_sha: dependency_fingerprint
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def subject_source_unchanged?(mutant, verdict)
|
|
97
|
+
VerdictFingerprint.subject_source_hash(mutant) == verdict.fetch(:subject_source_hash)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -10,7 +10,11 @@ module Henitai
|
|
|
10
10
|
|
|
11
11
|
def debug_child? = ENV["HENITAI_DEBUG_CHILD"] == "1"
|
|
12
12
|
|
|
13
|
+
# Gated here (not only at call sites) so no unguarded caller can leak
|
|
14
|
+
# debug lines into every child log by default.
|
|
13
15
|
def debug_child_puts(message)
|
|
16
|
+
return unless debug_child?
|
|
17
|
+
|
|
14
18
|
$stdout.puts(message)
|
|
15
19
|
$stdout.flush
|
|
16
20
|
end
|
|
@@ -79,7 +83,7 @@ module Henitai
|
|
|
79
83
|
|
|
80
84
|
def debug_child_activation_check
|
|
81
85
|
location = begin
|
|
82
|
-
Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":")
|
|
86
|
+
Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":") # henitai:disable
|
|
83
87
|
rescue StandardError
|
|
84
88
|
nil
|
|
85
89
|
end
|
|
@@ -105,7 +109,7 @@ module Henitai
|
|
|
105
109
|
end
|
|
106
110
|
|
|
107
111
|
def rspec_world_example_count # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
108
|
-
world = ::RSpec.
|
|
112
|
+
world = ::RSpec.world
|
|
109
113
|
world.example_count
|
|
110
114
|
rescue StandardError
|
|
111
115
|
nil
|
|
@@ -18,7 +18,15 @@ module Henitai
|
|
|
18
18
|
# mutant child runs. Coverage artifacts are only required during the
|
|
19
19
|
# dedicated bootstrap phase.
|
|
20
20
|
module CoverageRuntimeSuppressors
|
|
21
|
+
# True once coverage startup has been suppressed in this process —
|
|
22
|
+
# consumers (e.g. PerTestCoverageCollector) can then treat missing
|
|
23
|
+
# coverage as expected instead of warning about it.
|
|
24
|
+
def self.active? = @active ? true : false
|
|
25
|
+
|
|
26
|
+
def self.activate! = @active = true
|
|
27
|
+
|
|
21
28
|
def self.suppress_simplecov!
|
|
29
|
+
activate!
|
|
22
30
|
require "simplecov"
|
|
23
31
|
sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
|
|
24
32
|
sc.external_at_exit = true if sc.respond_to?(:external_at_exit=)
|
|
@@ -30,6 +38,7 @@ module Henitai
|
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
def self.suppress_coverage!
|
|
41
|
+
activate!
|
|
33
42
|
require "coverage"
|
|
34
43
|
cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
|
|
35
44
|
return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
|