henitai 0.5.3 → 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 +222 -1
- data/README.md +100 -13
- 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 +36 -10
- 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 +7 -5
- 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 +225 -45
- metadata +46 -3
- data/lib/henitai/survivor_test_filter.rb +0 -72
|
@@ -5,8 +5,11 @@ require_relative "../parser_current"
|
|
|
5
5
|
module Henitai
|
|
6
6
|
module Operators
|
|
7
7
|
# Replaces generic method call results with nil.
|
|
8
|
+
#
|
|
9
|
+
# A call with a block is replaced together with its block: replacing only
|
|
10
|
+
# the call would leave +nil { ... }+ behind, which does not parse.
|
|
8
11
|
class MethodExpression < Henitai::Operator
|
|
9
|
-
NODE_TYPES = [
|
|
12
|
+
NODE_TYPES = %i[send block numblock].freeze
|
|
10
13
|
EXCLUDED_METHODS = %i[
|
|
11
14
|
+
|
|
12
15
|
-
|
|
@@ -31,9 +34,10 @@ module Henitai
|
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
def mutate(node, subject:)
|
|
34
|
-
|
|
37
|
+
call = called_method(node)
|
|
38
|
+
return [] unless call&.type == :send
|
|
35
39
|
|
|
36
|
-
_receiver, method_name, *_arguments =
|
|
40
|
+
_receiver, method_name, *_arguments = call.children
|
|
37
41
|
return [] if excluded_method?(method_name)
|
|
38
42
|
|
|
39
43
|
[
|
|
@@ -48,6 +52,13 @@ module Henitai
|
|
|
48
52
|
|
|
49
53
|
private
|
|
50
54
|
|
|
55
|
+
def called_method(node)
|
|
56
|
+
case node.type
|
|
57
|
+
when :send then node
|
|
58
|
+
when :block, :numblock then node.children.first
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
def excluded_method?(method_name)
|
|
52
63
|
method_name.to_s.end_with?("=") || EXCLUDED_METHODS.include?(method_name)
|
|
53
64
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Operators
|
|
5
|
+
# Locates the regexp metacharacters that RegexMutator edits.
|
|
6
|
+
#
|
|
7
|
+
# An escaped character and the contents of a character class are literal
|
|
8
|
+
# text: the +^+ in +[^0-9]+ negates the class and is no anchor, and the
|
|
9
|
+
# +++ in +[+-]+ or +\++ is no quantifier. The scan therefore skips
|
|
10
|
+
# escapes and tracks character-class nesting, including POSIX brackets
|
|
11
|
+
# such as +[[:alpha:]]+.
|
|
12
|
+
class RegexMetacharacters
|
|
13
|
+
ANCHORS = %w[^ $].freeze
|
|
14
|
+
QUANTIFIERS = %w[+ * ?].freeze
|
|
15
|
+
GROUPING = %w[( |].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(source)
|
|
18
|
+
@source = source
|
|
19
|
+
@found = Hash.new { |hash, key| hash[key] = [] }
|
|
20
|
+
scan
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Indices of the +char+ quantifier ("+" or "*") applied to an atom.
|
|
24
|
+
def quantifiers(char)
|
|
25
|
+
@found[[:quantifier, char]].dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Indices of the +char+ anchor ("^" or "$").
|
|
29
|
+
def anchors(char)
|
|
30
|
+
@found[[:anchor, char]].dup
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Indices of the opening brackets of top-level, non-negated classes.
|
|
34
|
+
def class_openers
|
|
35
|
+
@found[:class_opener].dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def scan
|
|
41
|
+
@index = 0
|
|
42
|
+
@depth = 0
|
|
43
|
+
@previous = nil
|
|
44
|
+
step while @index < @source.length
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def step
|
|
48
|
+
char = @source[@index]
|
|
49
|
+
if char == "\\"
|
|
50
|
+
@previous = :atom
|
|
51
|
+
@index += 2
|
|
52
|
+
else
|
|
53
|
+
@depth.positive? ? within_class(char) : top_level(char)
|
|
54
|
+
@index += 1
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def within_class(char)
|
|
59
|
+
@depth += 1 if char == "["
|
|
60
|
+
@depth -= 1 if char == "]"
|
|
61
|
+
@previous = :atom if @depth.zero?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def top_level(char)
|
|
65
|
+
record(char)
|
|
66
|
+
@previous = kind_of_token(char)
|
|
67
|
+
@depth = 1 if char == "["
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def record(char)
|
|
71
|
+
if ANCHORS.include?(char)
|
|
72
|
+
@found[[:anchor, char]] << @index
|
|
73
|
+
elsif char == "["
|
|
74
|
+
@found[:class_opener] << @index unless @source[@index + 1] == "^"
|
|
75
|
+
elsif QUANTIFIERS.include?(char) && @previous == :atom
|
|
76
|
+
@found[[:quantifier, char]] << @index
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def kind_of_token(char)
|
|
81
|
+
return :anchor if ANCHORS.include?(char)
|
|
82
|
+
return :quantifier if QUANTIFIERS.include?(char) || char == "}"
|
|
83
|
+
return :group if GROUPING.include?(char)
|
|
84
|
+
|
|
85
|
+
:atom
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../parser_current"
|
|
4
|
+
require_relative "regex_metacharacters"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
6
7
|
module Operators
|
|
@@ -8,7 +9,10 @@ module Henitai
|
|
|
8
9
|
# and character-class negation.
|
|
9
10
|
#
|
|
10
11
|
# Each applicable transformation yields a separate mutant. Invalid
|
|
11
|
-
# results (unparseable regex) are discarded before emission.
|
|
12
|
+
# results (unparseable regex) are discarded before emission. Only static,
|
|
13
|
+
# single-segment literals are mutated: an interpolated or multi-line
|
|
14
|
+
# regexp is split into several parts, and rewriting one part alone would
|
|
15
|
+
# drop or misread the others.
|
|
12
16
|
# Anchor removal and character-class negation are intentionally one-way:
|
|
13
17
|
# they reduce noisy patterns rather than mirroring a full edit matrix.
|
|
14
18
|
class RegexMutator < Henitai::Operator
|
|
@@ -30,11 +34,11 @@ module Henitai
|
|
|
30
34
|
private
|
|
31
35
|
|
|
32
36
|
def extract_parts(node)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
37
|
+
*parts, opts_node = node.children
|
|
38
|
+
return unless opts_node.is_a?(Parser::AST::Node) && opts_node.type == :regopt
|
|
39
|
+
return unless parts.size == 1 && parts.first.is_a?(Parser::AST::Node) && parts.first.type == :str
|
|
36
40
|
|
|
37
|
-
[
|
|
41
|
+
[parts.first.children[0], opts_node]
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def build_regex_mutant(node, opts_node, new_source, original, description, subject:) # rubocop:disable Metrics/ParameterLists
|
|
@@ -47,35 +51,26 @@ module Henitai
|
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
def transformations(source)
|
|
54
|
+
found = RegexMetacharacters.new(source)
|
|
50
55
|
[
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
[edit(source, found.quantifiers("+"), "*"), "replaced + quantifier with *"],
|
|
57
|
+
[edit(source, found.quantifiers("*"), "+"), "replaced * quantifier with +"],
|
|
58
|
+
# Anchors are removed, never added, and character classes are only
|
|
59
|
+
# negated: the reverse edits would just recreate an input.
|
|
60
|
+
[edit(source, found.anchors("^").first(1), ""), "removed ^ anchor"],
|
|
61
|
+
[edit(source, found.anchors("$").last(1), ""), "removed $ anchor"],
|
|
62
|
+
[insert_after(source, found.class_openers, "^"), "negated character class"]
|
|
54
63
|
]
|
|
55
64
|
end
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
[
|
|
61
|
-
[source.gsub(/(?<=[^*+?\\])\+/, "*"), "replaced + quantifier with *"],
|
|
62
|
-
[source.gsub(/(?<=[^*+?\\])\*/, "+"), "replaced * quantifier with +"]
|
|
63
|
-
]
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def anchor_removals(source)
|
|
67
|
-
# Anchors are removed, not added back. The mutation is deliberately
|
|
68
|
-
# asymmetric because "restoring" anchors would just recreate the input.
|
|
69
|
-
[
|
|
70
|
-
[source.sub("^", ""), "removed ^ anchor"],
|
|
71
|
-
[source.sub(/\$$/, ""), "removed $ anchor"]
|
|
72
|
-
]
|
|
66
|
+
# Replaces the single characters at +indices+, from the last to the
|
|
67
|
+
# first so earlier indices stay valid.
|
|
68
|
+
def edit(source, indices, text)
|
|
69
|
+
indices.sort.reverse.each_with_object(source.dup) { |index, result| result[index] = text }
|
|
73
70
|
end
|
|
74
71
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
# emit the reverse mutation because that would mirror the same edit.
|
|
78
|
-
[[source.gsub(/\[(?!\^)/, "[^"), "negated character class"]]
|
|
72
|
+
def insert_after(source, indices, text)
|
|
73
|
+
indices.sort.reverse.each_with_object(source.dup) { |index, result| result.insert(index + 1, text) }
|
|
79
74
|
end
|
|
80
75
|
|
|
81
76
|
def valid_regex?(source)
|
|
@@ -47,7 +47,7 @@ module Henitai
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def mutate_implicit_return(node, subject:)
|
|
50
|
-
return [] unless node
|
|
50
|
+
return [] unless node.equal?(final_expression_node(subject&.ast_node))
|
|
51
51
|
|
|
52
52
|
replacement_nodes(node).map do |replacement|
|
|
53
53
|
build_mutant(
|
data/lib/henitai/operators.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Henitai
|
|
|
24
24
|
autoload :MethodExpression, "henitai/operators/method_expression"
|
|
25
25
|
autoload :AssignmentExpression, "henitai/operators/assignment_expression"
|
|
26
26
|
autoload :MethodChainUnwrap, "henitai/operators/method_chain_unwrap"
|
|
27
|
+
autoload :RegexMetacharacters, "henitai/operators/regex_metacharacters"
|
|
27
28
|
autoload :RegexMutator, "henitai/operators/regex_mutator"
|
|
28
29
|
autoload :UnaryOperator, "henitai/operators/unary_operator"
|
|
29
30
|
autoload :UpdateOperator, "henitai/operators/update_operator"
|
|
@@ -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
|
|