henitai 0.5.1 → 0.6.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 +296 -1
- data/README.md +109 -16
- data/assets/schema/henitai.schema.json +20 -0
- data/lib/henitai/arid_node_filter.rb +4 -19
- data/lib/henitai/bootstrap_error_message.rb +20 -0
- data/lib/henitai/canonical_report_merger.rb +23 -2
- data/lib/henitai/cli/run_command.rb +25 -7
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +4 -1
- data/lib/henitai/configuration.rb +4 -1
- data/lib/henitai/configuration_validator/rules.rb +8 -0
- data/lib/henitai/configuration_validator/scalars.rb +9 -0
- data/lib/henitai/configuration_validator.rb +2 -1
- data/lib/henitai/constant_path.rb +48 -0
- data/lib/henitai/control_run.rb +73 -0
- data/lib/henitai/coverage_bootstrapper.rb +57 -15
- data/lib/henitai/coverage_formatter.rb +26 -3
- data/lib/henitai/coverage_report_reader.rb +21 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
- data/lib/henitai/equivalence_detector.rb +46 -38
- data/lib/henitai/example_summary.rb +24 -0
- data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
- data/lib/henitai/execution_engine.rb +75 -60
- data/lib/henitai/fidelity_check.rb +173 -0
- data/lib/henitai/flaky_detection_baseline.rb +61 -0
- data/lib/henitai/flaky_mutant_retrier.rb +43 -0
- data/lib/henitai/flaky_retry_decision.rb +23 -0
- data/lib/henitai/flaky_test_registry.rb +63 -0
- data/lib/henitai/fork_server/client.rb +231 -0
- data/lib/henitai/fork_server/group_killer.rb +48 -0
- data/lib/henitai/fork_server/group_terminator.rb +47 -0
- data/lib/henitai/fork_server/launcher.rb +38 -0
- data/lib/henitai/fork_server/mutant_runner.rb +141 -0
- data/lib/henitai/fork_server/protocol.rb +73 -0
- data/lib/henitai/fork_server/reaper.rb +24 -0
- data/lib/henitai/fork_server/runtime.rb +109 -0
- data/lib/henitai/fork_server/server.rb +108 -0
- data/lib/henitai/fork_server/server_process.rb +73 -0
- data/lib/henitai/fork_server/status.rb +52 -0
- data/lib/henitai/fork_server.rb +43 -0
- data/lib/henitai/generated_artifacts.rb +3 -0
- data/lib/henitai/git_diff_analyzer.rb +28 -11
- data/lib/henitai/incremental_filter.rb +20 -10
- data/lib/henitai/integration/base.rb +72 -5
- data/lib/henitai/integration/capped_log_sink.rb +34 -0
- data/lib/henitai/integration/child_bootstrap.rb +7 -0
- data/lib/henitai/integration/child_report_store.rb +36 -0
- data/lib/henitai/integration/minitest.rb +17 -7
- data/lib/henitai/integration/minitest_autorun.rb +20 -0
- data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
- data/lib/henitai/integration/minitest_preload.rb +30 -0
- data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
- data/lib/henitai/integration/minitest_test_runner.rb +32 -15
- data/lib/henitai/integration/mutant_run_support.rb +73 -13
- data/lib/henitai/integration/rspec_child_runner.rb +44 -9
- data/lib/henitai/integration/rspec_preload.rb +35 -0
- data/lib/henitai/integration/rspec_process_runner.rb +4 -1
- data/lib/henitai/integration/scenario_log_support.rb +31 -18
- data/lib/henitai/integration.rb +16 -3
- data/lib/henitai/minitest_coverage_reporter.rb +22 -6
- data/lib/henitai/minitest_result_file.rb +62 -0
- data/lib/henitai/mutant/activation.rb +32 -0
- data/lib/henitai/mutant/activator.rb +75 -55
- data/lib/henitai/mutant/def_injection.rb +136 -0
- data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
- data/lib/henitai/mutant/lexical_target.rb +105 -0
- data/lib/henitai/mutant/replacement.rb +52 -0
- data/lib/henitai/mutant.rb +27 -8
- data/lib/henitai/mutant_generator.rb +62 -18
- data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
- data/lib/henitai/mutant_history_store.rb +14 -4
- data/lib/henitai/mutant_identity.rb +5 -1
- data/lib/henitai/mutant_verdict.rb +71 -0
- data/lib/henitai/operators/array_declaration.rb +4 -0
- data/lib/henitai/operators/assignment_expression.rb +2 -0
- data/lib/henitai/operators/conditional_expression.rb +21 -1
- data/lib/henitai/operators/hash_literal.rb +7 -4
- data/lib/henitai/operators/method_expression.rb +14 -3
- data/lib/henitai/operators/regex_metacharacters.rb +89 -0
- data/lib/henitai/operators/regex_mutator.rb +23 -28
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +11 -7
- data/lib/henitai/per_test_coverage_collector.rb +30 -31
- data/lib/henitai/per_test_coverage_delta.rb +60 -0
- data/lib/henitai/per_test_coverage_selector.rb +26 -13
- data/lib/henitai/process_wakeup.rb +8 -3
- data/lib/henitai/process_worker_runner.rb +19 -5
- data/lib/henitai/reporter.rb +15 -8
- data/lib/henitai/result.rb +32 -16
- data/lib/henitai/reuse_context.rb +50 -0
- data/lib/henitai/rspec_coverage_formatter.rb +4 -0
- data/lib/henitai/rspec_outcomes.rb +72 -0
- data/lib/henitai/runner.rb +58 -19
- data/lib/henitai/runner_dependencies.rb +22 -1
- data/lib/henitai/sampling_strategy.rb +36 -6
- data/lib/henitai/scenario_execution_result.rb +28 -5
- data/lib/henitai/signature_line.rb +21 -0
- data/lib/henitai/slot_scheduler/draining.rb +12 -2
- data/lib/henitai/slot_scheduler/process_control.rb +33 -0
- data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
- data/lib/henitai/slot_scheduler.rb +47 -8
- data/lib/henitai/source_file_selection.rb +1 -1
- data/lib/henitai/source_parser.rb +29 -9
- data/lib/henitai/static_filter.rb +45 -14
- data/lib/henitai/stillborn_filter.rb +70 -2
- data/lib/henitai/subject.rb +8 -3
- data/lib/henitai/subject_resolver.rb +65 -31
- data/lib/henitai/suite_boot_clock.rb +48 -0
- data/lib/henitai/survivor_activation_cache.rb +18 -11
- data/lib/henitai/survivor_rerun_strategy.rb +47 -43
- data/lib/henitai/test_file_outcomes.rb +79 -0
- data/lib/henitai/timeout_calibrator.rb +31 -9
- data/lib/henitai/timeout_ceiling.rb +37 -0
- data/lib/henitai/verdict_fingerprint.rb +29 -30
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +17 -1
- data/sig/configuration_validator.rbs +1 -0
- data/sig/henitai.rbs +227 -45
- metadata +46 -3
- data/lib/henitai/survivor_test_filter.rb +0 -72
|
@@ -35,12 +35,7 @@ module Henitai
|
|
|
35
35
|
# @return [Array<String>] sorted; empty when the test is unknown or the
|
|
36
36
|
# map is unavailable.
|
|
37
37
|
def source_files_covered_by(test_file)
|
|
38
|
-
|
|
39
|
-
map.select { |test, _| File.expand_path(test) == wanted }
|
|
40
|
-
.values
|
|
41
|
-
.flat_map(&:keys)
|
|
42
|
-
.uniq
|
|
43
|
-
.sort
|
|
38
|
+
Array(map_by_absolute_test_path[File.expand_path(test_file.to_s)]&.keys).sort
|
|
44
39
|
end
|
|
45
40
|
|
|
46
41
|
# True when the given test file's recorded coverage intersects the
|
|
@@ -63,11 +58,20 @@ module Henitai
|
|
|
63
58
|
mutant.location[:end_line]
|
|
64
59
|
end
|
|
65
60
|
|
|
61
|
+
# RSpec records test files as "./spec/x_spec.rb" while candidates arrive
|
|
62
|
+
# as "spec/x_spec.rb"; both resolve to the same absolute key.
|
|
66
63
|
def coverage_lines_for(test, mutant)
|
|
67
|
-
source_map =
|
|
64
|
+
source_map = map_by_absolute_test_path[File.expand_path(test.to_s)] || {}
|
|
68
65
|
Array(source_map[File.expand_path(mutant.location[:file])]).uniq
|
|
69
66
|
end
|
|
70
67
|
|
|
68
|
+
def map_by_absolute_test_path
|
|
69
|
+
@map_by_absolute_test_path ||= map.each_with_object({}) do |(test, source_map), index|
|
|
70
|
+
merged = index[File.expand_path(test)] ||= {}
|
|
71
|
+
source_map.each { |file, lines| merged[file] = Array(merged[file]) + Array(lines) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
71
75
|
def mutant_lines(mutant)
|
|
72
76
|
(mutant.location[:start_line]..mutant.location[:end_line]).to_a
|
|
73
77
|
end
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
require "coverage"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "json"
|
|
6
|
+
require_relative "per_test_coverage_delta"
|
|
7
|
+
require_relative "suite_boot_clock"
|
|
6
8
|
|
|
7
9
|
module Henitai
|
|
8
10
|
# Accumulates per-test line coverage deltas across test examples.
|
|
11
|
+
# {PerTestCoverageDelta} decides which lines a single test executed.
|
|
9
12
|
#
|
|
10
13
|
# Framework-agnostic core used by both the RSpec and Minitest adapters.
|
|
11
14
|
# Callers invoke record_test after each test completes and write_report
|
|
@@ -13,16 +16,25 @@ module Henitai
|
|
|
13
16
|
class PerTestCoverageCollector
|
|
14
17
|
REPORT_DIR_ENV = "HENITAI_REPORTS_DIR"
|
|
15
18
|
REPORT_FILE_NAME = "henitai_per_test.json"
|
|
19
|
+
BOOT_FILE_NAME = "henitai_suite_boot.json"
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
# @param boot_clock [SuiteBootClock] measures suite boot time
|
|
22
|
+
def initialize(boot_clock: SuiteBootClock.from_env(ENV))
|
|
23
|
+
@boot_clock = boot_clock
|
|
18
24
|
@coverage_by_test = Hash.new do |hash, test_file|
|
|
19
25
|
hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
|
|
20
26
|
end
|
|
21
27
|
@duration_by_test = Hash.new(0.0)
|
|
22
28
|
@previous_snapshot = {}
|
|
29
|
+
@delta = PerTestCoverageDelta.new
|
|
23
30
|
@warned_missing_coverage = false
|
|
24
31
|
end
|
|
25
32
|
|
|
33
|
+
# Called when the first test of the suite starts; ends the boot phase.
|
|
34
|
+
def test_started
|
|
35
|
+
@boot_clock.mark_first_test_started
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
def record_test(test_file, duration: nil)
|
|
27
39
|
snapshot = current_snapshot
|
|
28
40
|
return warn_missing_coverage unless snapshot
|
|
@@ -36,19 +48,34 @@ module Henitai
|
|
|
36
48
|
@previous_snapshot = snapshot
|
|
37
49
|
end
|
|
38
50
|
|
|
51
|
+
# Advances past a test whose result cannot be attributed to a file, so its
|
|
52
|
+
# coverage is not credited to the next test.
|
|
53
|
+
def skip_test
|
|
54
|
+
@previous_snapshot = current_snapshot || @previous_snapshot
|
|
55
|
+
end
|
|
56
|
+
|
|
39
57
|
def write_report
|
|
40
58
|
return if @coverage_by_test.empty?
|
|
41
59
|
|
|
42
|
-
FileUtils.mkdir_p(
|
|
60
|
+
FileUtils.mkdir_p(reports_dir)
|
|
43
61
|
File.write(report_path, JSON.pretty_generate(serializable_report))
|
|
62
|
+
write_boot_report if @boot_clock.measured?
|
|
44
63
|
end
|
|
45
64
|
|
|
46
65
|
private
|
|
47
66
|
|
|
67
|
+
def write_boot_report
|
|
68
|
+
File.write(boot_path, JSON.pretty_generate("boot_seconds" => @boot_clock.boot_seconds))
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
def report_path
|
|
49
72
|
File.join(reports_dir, REPORT_FILE_NAME)
|
|
50
73
|
end
|
|
51
74
|
|
|
75
|
+
def boot_path
|
|
76
|
+
File.join(reports_dir, BOOT_FILE_NAME)
|
|
77
|
+
end
|
|
78
|
+
|
|
52
79
|
def reports_dir
|
|
53
80
|
ENV.fetch(REPORT_DIR_ENV, "coverage")
|
|
54
81
|
end
|
|
@@ -81,39 +108,11 @@ module Henitai
|
|
|
81
108
|
snapshot.each_with_object({}) do |(source_file, file_coverage), result|
|
|
82
109
|
next unless source_file?(source_file)
|
|
83
110
|
|
|
84
|
-
lines =
|
|
85
|
-
file_coverage,
|
|
86
|
-
previous_line_counts(source_file)
|
|
87
|
-
)
|
|
111
|
+
lines = @delta.executed_lines(file_coverage, @previous_snapshot.fetch(source_file, []))
|
|
88
112
|
result[source_file] = lines unless lines.empty?
|
|
89
113
|
end
|
|
90
114
|
end
|
|
91
115
|
|
|
92
|
-
# Count-delta attribution: a line belongs to every test whose run
|
|
93
|
-
# incremented its cumulative count, not just the first toucher. This keeps
|
|
94
|
-
# the per-test map a complete over-approximation of reachability — the
|
|
95
|
-
# soundness precondition for survivor verdict reuse (ADR-11).
|
|
96
|
-
def new_line_numbers(file_coverage, previous_counts)
|
|
97
|
-
line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
|
|
98
|
-
next unless count.to_i > previous_counts.fetch(index, 0).to_i
|
|
99
|
-
|
|
100
|
-
index + 1
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def previous_line_counts(source_file)
|
|
105
|
-
line_counts_for(@previous_snapshot.fetch(source_file, []))
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def line_counts_for(file_coverage)
|
|
109
|
-
case file_coverage
|
|
110
|
-
when Hash
|
|
111
|
-
Array(file_coverage[:lines] || file_coverage["lines"])
|
|
112
|
-
else
|
|
113
|
-
Array(file_coverage)
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
116
|
def source_file?(path)
|
|
118
117
|
expanded = File.expand_path(path)
|
|
119
118
|
prefix = "#{Dir.pwd}#{File::SEPARATOR}"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Computes which source lines a test executed in one file, from two
|
|
5
|
+
# cumulative +Coverage.peek_result+ entries (before and after the test).
|
|
6
|
+
#
|
|
7
|
+
# Line coverage alone misses endless methods (+def x = expr+): calling
|
|
8
|
+
# one never increments any line count, so their body lines would only be
|
|
9
|
+
# attributed to the test that loaded the file. When method coverage is
|
|
10
|
+
# available, every called method also contributes its +def+ line and the
|
|
11
|
+
# lines inside its range that line coverage does not track.
|
|
12
|
+
class PerTestCoverageDelta
|
|
13
|
+
def executed_lines(current, previous)
|
|
14
|
+
(counted_lines(current, previous) | called_method_lines(current, previous)).sort
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Count-delta attribution: a line belongs to every test whose run
|
|
20
|
+
# incremented its cumulative count, not just the first toucher. This keeps
|
|
21
|
+
# the per-test map a complete over-approximation of reachability — the
|
|
22
|
+
# soundness precondition for survivor verdict reuse (ADR-11).
|
|
23
|
+
def counted_lines(current, previous)
|
|
24
|
+
previous_counts = line_counts(previous)
|
|
25
|
+
line_counts(current).each_with_index.filter_map do |count, index|
|
|
26
|
+
index + 1 if count.to_i > previous_counts.fetch(index, 0).to_i
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def called_method_lines(current, previous)
|
|
31
|
+
counts = line_counts(current)
|
|
32
|
+
previous_calls = method_counts(previous)
|
|
33
|
+
method_counts(current).flat_map do |key, calls|
|
|
34
|
+
next [] unless calls.to_i > previous_calls.fetch(key, 0).to_i
|
|
35
|
+
|
|
36
|
+
untracked_method_lines(key, counts)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def untracked_method_lines(key, counts)
|
|
41
|
+
first_line = key[2]
|
|
42
|
+
last_line = key[4]
|
|
43
|
+
(first_line..last_line).select do |line|
|
|
44
|
+
line == first_line || counts[line - 1].nil?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def line_counts(file_coverage)
|
|
49
|
+
return Array(file_coverage) unless file_coverage.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
Array(file_coverage[:lines] || file_coverage["lines"])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def method_counts(file_coverage)
|
|
55
|
+
return {} unless file_coverage.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
file_coverage[:methods] || file_coverage["methods"] || {}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -1,30 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Henitai
|
|
4
|
-
#
|
|
4
|
+
# Selects the test files to run for a mutant, coverage first.
|
|
5
5
|
#
|
|
6
|
-
# The
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# The per-test coverage map is the primary source: every integration test
|
|
7
|
+
# file that provably reaches the mutant's lines is selected, whether or not
|
|
8
|
+
# its name matches the subject. Name matching ({fallback}) is only consulted
|
|
9
|
+
# when the map cannot decide — no map, no location, no covering test in the
|
|
10
|
+
# map, or no covering test left in the integration. Over-selection costs
|
|
11
|
+
# time; a missed covering test would produce a false survivor. Mutants on a
|
|
12
|
+
# method's def line additionally take the name-matched tests, because that
|
|
13
|
+
# line's coverage is only a load-time record.
|
|
14
|
+
#
|
|
15
|
+
# `test_files` and `fallback` are lambdas so that expensive name matching
|
|
16
|
+
# and file discovery run only when they are needed.
|
|
10
17
|
class PerTestCoverageSelector
|
|
11
18
|
def initialize(coverage_report_reader: CoverageReportReader.new)
|
|
12
19
|
@coverage_report_reader = coverage_report_reader
|
|
13
20
|
end
|
|
14
21
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
return
|
|
22
|
+
def select(mutant, reports_dir:, test_files:, fallback:)
|
|
23
|
+
covering = per_test_coverage(reports_dir).tests_covering(mutant)
|
|
24
|
+
return fallback.call if covering.empty?
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
covered_tests.empty? ? candidates : covered_tests
|
|
26
|
+
covered = covered_integration_tests(test_files.call, covering)
|
|
27
|
+
return fallback.call if covered.empty?
|
|
28
|
+
|
|
29
|
+
SignatureLine.on?(mutant) ? covered | fallback.call : covered
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
private
|
|
27
33
|
|
|
34
|
+
def covered_integration_tests(integration_tests, covering)
|
|
35
|
+
covering_paths = covering.map { |test| File.expand_path(test) }
|
|
36
|
+
Array(integration_tests).select do |test|
|
|
37
|
+
covering_paths.include?(File.expand_path(test))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
28
41
|
def per_test_coverage(reports_dir)
|
|
29
42
|
@per_test_coverage ||= {}
|
|
30
43
|
@per_test_coverage[reports_dir] ||= PerTestCoverage.new(
|
|
@@ -13,10 +13,15 @@ module Henitai
|
|
|
13
13
|
self
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Also wake on these sockets; +provider+ is asked for them on every wait,
|
|
17
|
+
# so a connection that was lost can drop out of the set.
|
|
18
|
+
def watch(&provider)
|
|
19
|
+
@watched = provider
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
16
23
|
def wait(timeout)
|
|
17
|
-
|
|
18
|
-
IO.select([reader], nil, nil, timeout)
|
|
19
|
-
# rubocop:enable Lint/IncompatibleIoSelectWithFiberScheduler
|
|
24
|
+
IO.select([reader, *@watched&.call], nil, nil, timeout)
|
|
20
25
|
rescue Errno::EINTR
|
|
21
26
|
nil
|
|
22
27
|
end
|
|
@@ -27,6 +27,10 @@ module Henitai
|
|
|
27
27
|
Process.wait(pid)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def pause(seconds)
|
|
31
|
+
Kernel.sleep(seconds)
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
def trap(signal, handler = nil, &block)
|
|
31
35
|
Kernel.trap(signal, handler || block)
|
|
32
36
|
end
|
|
@@ -35,10 +39,11 @@ module Henitai
|
|
|
35
39
|
# Loop primitives the {SlotScheduler} reads back from its host.
|
|
36
40
|
attr_reader :worker_count, :runtime, :wakeup
|
|
37
41
|
|
|
38
|
-
def initialize(worker_count:, runtime: Runtime.new, wakeup: nil)
|
|
42
|
+
def initialize(worker_count:, runtime: Runtime.new, wakeup: nil, flaky_tests: nil)
|
|
39
43
|
@worker_count = worker_count
|
|
40
44
|
@runtime = runtime
|
|
41
45
|
@wakeup = wakeup
|
|
46
|
+
@flaky_tests = flaky_tests
|
|
42
47
|
@shutdown_requested = false
|
|
43
48
|
end
|
|
44
49
|
|
|
@@ -85,18 +90,21 @@ module Henitai
|
|
|
85
90
|
def prepare_run(mutants, integration, config, progress_reporter, options)
|
|
86
91
|
@shutdown_requested = false
|
|
87
92
|
@wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
|
|
93
|
+
@wakeup.watch { @runtime.wakeup_ios } if @runtime.respond_to?(:wakeup_ios)
|
|
88
94
|
@scheduler = SlotScheduler.new(
|
|
89
95
|
integration: integration,
|
|
90
96
|
config: config,
|
|
91
97
|
progress_reporter: progress_reporter,
|
|
92
98
|
options: options,
|
|
93
|
-
host: self
|
|
99
|
+
host: self,
|
|
100
|
+
flaky_tests: @flaky_tests
|
|
94
101
|
)
|
|
95
102
|
@scheduler.enqueue(mutants)
|
|
96
103
|
end
|
|
97
104
|
|
|
98
105
|
def event_loop
|
|
99
106
|
saved_traps = install_signal_traps
|
|
107
|
+
@scheduler.fill_idle_slots
|
|
100
108
|
loop do
|
|
101
109
|
break if @scheduler.done?
|
|
102
110
|
|
|
@@ -108,7 +116,6 @@ module Henitai
|
|
|
108
116
|
end
|
|
109
117
|
|
|
110
118
|
def process_cycle
|
|
111
|
-
@scheduler.fill_idle_slots unless @shutdown_requested
|
|
112
119
|
@scheduler.reap_all_completed_children
|
|
113
120
|
@scheduler.check_timeouts
|
|
114
121
|
@scheduler.fill_idle_slots unless @shutdown_requested
|
|
@@ -129,14 +136,21 @@ module Henitai
|
|
|
129
136
|
end
|
|
130
137
|
|
|
131
138
|
def wait_for_next_event
|
|
132
|
-
@wakeup&.wait(
|
|
139
|
+
@wakeup&.wait(next_wait_timeout)
|
|
133
140
|
@wakeup&.drain
|
|
134
141
|
end
|
|
135
142
|
|
|
143
|
+
# An event the runtime already read off its socket cannot wake the select.
|
|
144
|
+
def next_wait_timeout
|
|
145
|
+
return 0 if @runtime.respond_to?(:pending_events?) && @runtime.pending_events?
|
|
146
|
+
|
|
147
|
+
@scheduler.next_event_timeout
|
|
148
|
+
end
|
|
149
|
+
|
|
136
150
|
def install_signal_traps
|
|
137
151
|
saved = {}
|
|
138
152
|
%w[INT TERM HUP].each do |sig|
|
|
139
|
-
saved[sig] = @runtime.trap(sig) {
|
|
153
|
+
saved[sig] = @runtime.trap(sig) { request_shutdown }
|
|
140
154
|
end
|
|
141
155
|
saved
|
|
142
156
|
end
|
data/lib/henitai/reporter.rb
CHANGED
|
@@ -71,6 +71,15 @@ module Henitai
|
|
|
71
71
|
def canonical_path
|
|
72
72
|
File.join(config.reports_dir, "mutation-report.json")
|
|
73
73
|
end
|
|
74
|
+
|
|
75
|
+
# Scoped runs upload the merged canonical report so that they do not
|
|
76
|
+
# replace the full report with a partial one.
|
|
77
|
+
def merged_schema_for(result)
|
|
78
|
+
schema = result.to_stryker_schema
|
|
79
|
+
return schema if authoritative?(result)
|
|
80
|
+
|
|
81
|
+
CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
|
|
82
|
+
end
|
|
74
83
|
end
|
|
75
84
|
|
|
76
85
|
# Terminal reporter.
|
|
@@ -395,6 +404,8 @@ module Henitai
|
|
|
395
404
|
|
|
396
405
|
FileUtils.mkdir_p(File.dirname(history_report_path))
|
|
397
406
|
File.write(history_report_path, JSON.pretty_generate(store.trend_report))
|
|
407
|
+
rescue StandardError => e
|
|
408
|
+
warn "henitai: could not export mutation history (#{e.class}: #{e.message})"
|
|
398
409
|
end
|
|
399
410
|
|
|
400
411
|
def default_history_store
|
|
@@ -450,10 +461,7 @@ module Henitai
|
|
|
450
461
|
end
|
|
451
462
|
|
|
452
463
|
def schema_for(result)
|
|
453
|
-
|
|
454
|
-
return schema if authoritative?(result)
|
|
455
|
-
|
|
456
|
-
CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
|
|
464
|
+
merged_schema_for(result)
|
|
457
465
|
end
|
|
458
466
|
|
|
459
467
|
def escaped_report_json(schema)
|
|
@@ -515,9 +523,8 @@ module Henitai
|
|
|
515
523
|
end
|
|
516
524
|
|
|
517
525
|
def ignore_reason_for(mutant)
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
reason = mutant.ignore_reason
|
|
526
|
+
reason = mutant.status_reason if mutant.respond_to?(:status_reason)
|
|
527
|
+
reason ||= mutant.ignore_reason if mutant.respond_to?(:ignore_reason)
|
|
521
528
|
reason ? "(#{reason})" : nil
|
|
522
529
|
end
|
|
523
530
|
|
|
@@ -609,7 +616,7 @@ module Henitai
|
|
|
609
616
|
|
|
610
617
|
def build_request(result, uri)
|
|
611
618
|
request = Net::HTTP::Put.new(uri.request_uri, request_headers)
|
|
612
|
-
request.body = JSON.generate(result
|
|
619
|
+
request.body = JSON.generate(merged_schema_for(result))
|
|
613
620
|
request
|
|
614
621
|
end
|
|
615
622
|
|
data/lib/henitai/result.rb
CHANGED
|
@@ -26,9 +26,8 @@ module Henitai
|
|
|
26
26
|
process_abort: true
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
29
|
-
# Maps each criterion to the mutant status it governs.
|
|
30
|
-
#
|
|
31
|
-
# so that entry is forward-looking wiring rather than a live code path.
|
|
29
|
+
# Maps each criterion to the mutant status it governs. MutantVerdict emits
|
|
30
|
+
# :runtime_error for process aborts, signals, and unknown child outcomes.
|
|
32
31
|
CRITERION_STATUSES = {
|
|
33
32
|
test_result: :killed,
|
|
34
33
|
timeout: :timeout,
|
|
@@ -36,7 +35,7 @@ module Henitai
|
|
|
36
35
|
}.freeze
|
|
37
36
|
|
|
38
37
|
attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
|
|
39
|
-
:session_id, :git_sha, :since, :coverage_criteria
|
|
38
|
+
:session_id, :git_sha, :since, :coverage_criteria, :complete_files, :sampling
|
|
40
39
|
|
|
41
40
|
# @param source_provider [#call] maps a file path to its source string.
|
|
42
41
|
# Injected so the domain object performs no disk IO; the caller (which
|
|
@@ -48,12 +47,16 @@ module Henitai
|
|
|
48
47
|
# canonical report; non-authoritative (subject-scoped, --since,
|
|
49
48
|
# --survivors-from) runs are merged into it instead — see
|
|
50
49
|
# CanonicalReportMerger.
|
|
50
|
+
# @param complete_files [Array<String>, nil] source files this run mutated
|
|
51
|
+
# completely (every subject). The merger replaces a prior report's entry
|
|
52
|
+
# for such a file wholesale instead of merging by stableId, so stale
|
|
53
|
+
# mutants of changed or deleted code do not linger.
|
|
51
54
|
# rubocop:disable Metrics/ParameterLists
|
|
52
55
|
def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
|
|
53
56
|
partial_rerun: false, survivor_stats: nil,
|
|
54
57
|
session_id: SecureRandom.uuid, git_sha: nil,
|
|
55
58
|
source_provider: ->(_file) { "" }, authoritative: true,
|
|
56
|
-
since: nil, coverage_criteria: nil)
|
|
59
|
+
since: nil, coverage_criteria: nil, complete_files: nil, sampling: nil)
|
|
57
60
|
@mutants = mutants
|
|
58
61
|
@started_at = started_at
|
|
59
62
|
@finished_at = finished_at
|
|
@@ -65,7 +68,9 @@ module Henitai
|
|
|
65
68
|
@source_provider = source_provider
|
|
66
69
|
@authoritative = authoritative
|
|
67
70
|
@since = since
|
|
71
|
+
@complete_files = Array(complete_files)
|
|
68
72
|
@coverage_criteria = DEFAULT_COVERAGE_CRITERIA.merge(coverage_criteria || {})
|
|
73
|
+
@sampling = sampling
|
|
69
74
|
end
|
|
70
75
|
# rubocop:enable Metrics/ParameterLists
|
|
71
76
|
|
|
@@ -89,11 +94,12 @@ module Henitai
|
|
|
89
94
|
|
|
90
95
|
# Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
|
|
91
96
|
#
|
|
92
|
-
# MS = detected / (total − ignored − no_coverage − compile_error − equivalent)
|
|
97
|
+
# MS = detected / (total − ignored − no_coverage − compile_error − equivalent − pending)
|
|
93
98
|
#
|
|
94
99
|
# Confirmed equivalent mutants werden aus BEIDEN Seiten der Gleichung entfernt:
|
|
95
100
|
# Sie sind weder im Zähler (nicht detektierbar) noch im Nenner (nicht testbar).
|
|
96
|
-
# Das ist der entscheidende Unterschied zum MSI.
|
|
101
|
+
# Das ist der entscheidende Unterschied zum MSI. Pending mutants (left over from
|
|
102
|
+
# an interrupted run, without a verdict) are excluded from both sides as well.
|
|
97
103
|
#
|
|
98
104
|
# @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
|
|
99
105
|
def mutation_score
|
|
@@ -102,8 +108,9 @@ module Henitai
|
|
|
102
108
|
|
|
103
109
|
# Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
|
|
104
110
|
#
|
|
105
|
-
# MSI = killed /
|
|
111
|
+
# MSI = killed / (total − pending)
|
|
106
112
|
#
|
|
113
|
+
# Only mutants with a verdict count; pending mutants have none.
|
|
107
114
|
# MSI ist immer ≤ MS. Der Unterschied kommuniziert die Äquivalenz-Unsicherheit.
|
|
108
115
|
# Beide Werte MÜSSEN im Report zusammen ausgewiesen werden (Anti-Pattern: nur MS).
|
|
109
116
|
#
|
|
@@ -151,6 +158,8 @@ module Henitai
|
|
|
151
158
|
schema = base_schema
|
|
152
159
|
sha = @git_sha
|
|
153
160
|
schema[:gitSha] = sha if sha
|
|
161
|
+
schema[:completeFiles] = complete_files if complete_files.any? && !authoritative?
|
|
162
|
+
schema[:sampling] = @sampling if @sampling
|
|
154
163
|
return schema unless partial_rerun?
|
|
155
164
|
|
|
156
165
|
schema[:partialRerun] = true
|
|
@@ -171,7 +180,7 @@ module Henitai
|
|
|
171
180
|
end
|
|
172
181
|
|
|
173
182
|
def mutation_score_for(list)
|
|
174
|
-
excluded = %i[ignored no_coverage compile_error equivalent]
|
|
183
|
+
excluded = %i[ignored no_coverage compile_error equivalent pending]
|
|
175
184
|
valid = list.reject { |m| excluded.include?(m.status) }
|
|
176
185
|
return nil if valid.empty?
|
|
177
186
|
|
|
@@ -179,9 +188,10 @@ module Henitai
|
|
|
179
188
|
end
|
|
180
189
|
|
|
181
190
|
def mutation_score_indicator_for(list)
|
|
182
|
-
|
|
191
|
+
decided = list.reject { |m| m.status == :pending }
|
|
192
|
+
return nil if decided.empty?
|
|
183
193
|
|
|
184
|
-
((
|
|
194
|
+
((decided.count(&:killed?).to_f / decided.count) * 100.0).round(2).to_f
|
|
185
195
|
end
|
|
186
196
|
|
|
187
197
|
def base_schema
|
|
@@ -205,10 +215,16 @@ module Henitai
|
|
|
205
215
|
language: "ruby",
|
|
206
216
|
source: @source_provider.call(file_mutants.first.location[:file]),
|
|
207
217
|
mutants: file_mutants.map { |m| mutant_to_schema(m) }
|
|
208
|
-
}
|
|
218
|
+
}.merge(file_provenance)
|
|
209
219
|
end
|
|
210
220
|
end
|
|
211
221
|
|
|
222
|
+
# Vendor fields: which run produced this file entry. Kept per file so a
|
|
223
|
+
# merged report can tell which session snapshot holds its mutants.
|
|
224
|
+
def file_provenance
|
|
225
|
+
{ sessionId: @session_id, gitSha: @git_sha }.compact
|
|
226
|
+
end
|
|
227
|
+
|
|
212
228
|
def mutant_to_schema(mutant)
|
|
213
229
|
{
|
|
214
230
|
id: mutant.id,
|
|
@@ -261,11 +277,11 @@ module Henitai
|
|
|
261
277
|
end
|
|
262
278
|
|
|
263
279
|
# Serialized as the schema's statusReason so directive reasons show up
|
|
264
|
-
# next to the Ignored status
|
|
280
|
+
# next to the Ignored status, and harness reasons (a failed fidelity
|
|
281
|
+
# check, for example) next to CompileError, in the HTML report.
|
|
265
282
|
def status_reason_for(mutant)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
mutant.ignore_reason
|
|
283
|
+
reason = mutant.status_reason if mutant.respond_to?(:status_reason)
|
|
284
|
+
reason || (mutant.ignore_reason if mutant.respond_to?(:ignore_reason))
|
|
269
285
|
end
|
|
270
286
|
|
|
271
287
|
# Vendored extension field (like stableId): marks verdicts reused from
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "verdict_fingerprint"
|
|
7
|
+
|
|
8
|
+
module Henitai
|
|
9
|
+
# Run-level fingerprint of everything outside a mutant and its covering
|
|
10
|
+
# tests that can change a verdict: the test dependency files, the
|
|
11
|
+
# verdict-relevant configuration and the henitai version. Recorded with every
|
|
12
|
+
# cached Killed/Survived verdict and required to match for reuse.
|
|
13
|
+
#
|
|
14
|
+
# Computed once per instance, so the filter that reads verdicts and the
|
|
15
|
+
# history store that writes them always agree. nil when the dependency files
|
|
16
|
+
# cannot be read — nothing is then reusable.
|
|
17
|
+
class ReuseContext
|
|
18
|
+
def initialize(config:, version: Henitai::VERSION, root: Dir.pwd)
|
|
19
|
+
@config = config
|
|
20
|
+
@version = version
|
|
21
|
+
@root = root
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fingerprint
|
|
25
|
+
return @fingerprint if defined?(@fingerprint)
|
|
26
|
+
|
|
27
|
+
@fingerprint = compute
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def compute
|
|
33
|
+
dependencies = VerdictFingerprint.dependency_fingerprint(@root)
|
|
34
|
+
return nil unless dependencies
|
|
35
|
+
|
|
36
|
+
Digest::SHA256.hexdigest(JSON.generate(components(dependencies)))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def components(dependencies)
|
|
40
|
+
{
|
|
41
|
+
"dependencies" => dependencies,
|
|
42
|
+
"version" => @version,
|
|
43
|
+
"integration" => @config.integration.to_s,
|
|
44
|
+
"operators" => @config.operators.to_s,
|
|
45
|
+
"test_excludes" => Array(@config.test_excludes).map(&:to_s).sort,
|
|
46
|
+
"coverage_criteria" => @config.coverage_criteria.to_h.sort_by { |key, _| key.to_s }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|