henitai 0.1.2 → 0.1.3
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 +82 -1
- data/README.md +3 -1
- data/assets/schema/henitai.schema.json +0 -4
- data/lib/henitai/arid_node_filter.rb +3 -0
- data/lib/henitai/available_cpu_count.rb +79 -0
- data/lib/henitai/cli.rb +7 -4
- data/lib/henitai/configuration.rb +3 -5
- data/lib/henitai/configuration_validator.rb +1 -11
- data/lib/henitai/coverage_bootstrapper.rb +112 -7
- data/lib/henitai/coverage_report_reader.rb +67 -0
- data/lib/henitai/eager_load.rb +11 -0
- data/lib/henitai/equivalence_detector.rb +60 -1
- data/lib/henitai/execution_engine.rb +34 -22
- data/lib/henitai/integration/rspec_process_runner.rb +58 -0
- data/lib/henitai/integration.rb +192 -90
- data/lib/henitai/mutant.rb +3 -1
- data/lib/henitai/mutant_generator.rb +25 -48
- data/lib/henitai/operator.rb +6 -1
- data/lib/henitai/operators/assignment_expression.rb +7 -23
- data/lib/henitai/operators/conditional_expression.rb +1 -7
- data/lib/henitai/operators/method_chain_unwrap.rb +41 -0
- data/lib/henitai/operators/regex_mutator.rb +89 -0
- data/lib/henitai/operators/unary_operator.rb +36 -0
- data/lib/henitai/operators/update_operator.rb +70 -0
- data/lib/henitai/operators.rb +4 -0
- data/lib/henitai/parallel_execution_runner.rb +135 -0
- data/lib/henitai/per_test_coverage_selector.rb +60 -0
- data/lib/henitai/result.rb +16 -4
- data/lib/henitai/runner.rb +75 -11
- data/lib/henitai/source_parser.rb +12 -1
- data/lib/henitai/static_filter.rb +20 -41
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +3 -0
- data/sig/henitai.rbs +59 -10
- metadata +16 -3
|
@@ -4,13 +4,14 @@ require_relative "../parser_current"
|
|
|
4
4
|
|
|
5
5
|
module Henitai
|
|
6
6
|
module Operators
|
|
7
|
-
#
|
|
7
|
+
# Reduces ||= to a plain assignment, removing the memoization guard.
|
|
8
|
+
#
|
|
9
|
+
# Arithmetic compound assignments (+=, -=, *=, /=) are covered by
|
|
10
|
+
# UpdateOperator, which also handles the logical pair swap (||= ↔ &&=).
|
|
11
|
+
# AssignmentExpression is intentionally scoped to or_asgn reduction only
|
|
12
|
+
# to avoid emitting duplicate mutants in the full operator set.
|
|
8
13
|
class AssignmentExpression < Henitai::Operator
|
|
9
|
-
NODE_TYPES = %i[
|
|
10
|
-
OPERATOR_MAP = {
|
|
11
|
-
:+ => :-,
|
|
12
|
-
:- => :+
|
|
13
|
-
}.freeze
|
|
14
|
+
NODE_TYPES = %i[or_asgn].freeze
|
|
14
15
|
|
|
15
16
|
def self.node_types
|
|
16
17
|
NODE_TYPES
|
|
@@ -18,8 +19,6 @@ module Henitai
|
|
|
18
19
|
|
|
19
20
|
def mutate(node, subject:)
|
|
20
21
|
case node.type
|
|
21
|
-
when :op_asgn
|
|
22
|
-
mutate_compound_assignment(node, subject:)
|
|
23
22
|
when :or_asgn
|
|
24
23
|
# Memoization-style ||= is usually filtered earlier by AridNodeFilter.
|
|
25
24
|
mutate_coalesce_assignment(node, subject:)
|
|
@@ -30,21 +29,6 @@ module Henitai
|
|
|
30
29
|
|
|
31
30
|
private
|
|
32
31
|
|
|
33
|
-
def mutate_compound_assignment(node, subject:)
|
|
34
|
-
left, operator, right = node.children
|
|
35
|
-
replacement = OPERATOR_MAP[operator]
|
|
36
|
-
return [] unless replacement
|
|
37
|
-
|
|
38
|
-
[
|
|
39
|
-
build_mutant(
|
|
40
|
-
subject:,
|
|
41
|
-
original_node: node,
|
|
42
|
-
mutated_node: Parser::AST::Node.new(:op_asgn, [left, replacement, right]),
|
|
43
|
-
description: "replaced #{operator} with #{replacement}"
|
|
44
|
-
)
|
|
45
|
-
]
|
|
46
|
-
end
|
|
47
|
-
|
|
48
32
|
def mutate_coalesce_assignment(node, subject:)
|
|
49
33
|
left, right = node.children
|
|
50
34
|
mutated_node = assignment_node(left, right)
|
|
@@ -74,13 +74,7 @@ module Henitai
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def case_children(children)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if children.last&.type == :when
|
|
80
|
-
[children, nil]
|
|
81
|
-
else
|
|
82
|
-
[children[0...-1], children.last]
|
|
83
|
-
end
|
|
77
|
+
[children[0...-1], children.last]
|
|
84
78
|
end
|
|
85
79
|
|
|
86
80
|
def condition_variants(node, subject:, condition:)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser_current"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Operators
|
|
7
|
+
# Removes individual links from a method chain by replacing the outer
|
|
8
|
+
# send node with its receiver.
|
|
9
|
+
#
|
|
10
|
+
# Only fires when the immediate receiver is itself a :send node, which
|
|
11
|
+
# naturally excludes block-receiver chains (list.select { }.count) and
|
|
12
|
+
# standalone calls.
|
|
13
|
+
#
|
|
14
|
+
# Example: array.uniq.sort.first
|
|
15
|
+
# → array.uniq.sort (removed .first)
|
|
16
|
+
# → array.uniq (removed .sort)
|
|
17
|
+
# → array (removed .uniq) — via the :uniq node
|
|
18
|
+
class MethodChainUnwrap < Henitai::Operator
|
|
19
|
+
NODE_TYPES = %i[send].freeze
|
|
20
|
+
|
|
21
|
+
def self.node_types
|
|
22
|
+
NODE_TYPES
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mutate(node, subject:)
|
|
26
|
+
receiver = node.children[0]
|
|
27
|
+
return [] unless receiver.is_a?(Parser::AST::Node) && receiver.type == :send
|
|
28
|
+
|
|
29
|
+
method_name = node.children[1]
|
|
30
|
+
[
|
|
31
|
+
build_mutant(
|
|
32
|
+
subject:,
|
|
33
|
+
original_node: node,
|
|
34
|
+
mutated_node: receiver,
|
|
35
|
+
description: "removed .#{method_name} from chain"
|
|
36
|
+
)
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser_current"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Operators
|
|
7
|
+
# Mutates regular expression literals by altering quantifiers, anchors,
|
|
8
|
+
# and character-class negation.
|
|
9
|
+
#
|
|
10
|
+
# Each applicable transformation yields a separate mutant. Invalid
|
|
11
|
+
# results (unparseable regex) are discarded before emission.
|
|
12
|
+
# Anchor removal and character-class negation are intentionally one-way:
|
|
13
|
+
# they reduce noisy patterns rather than mirroring a full edit matrix.
|
|
14
|
+
class RegexMutator < Henitai::Operator
|
|
15
|
+
NODE_TYPES = %i[regexp].freeze
|
|
16
|
+
|
|
17
|
+
def self.node_types
|
|
18
|
+
NODE_TYPES
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def mutate(node, subject:)
|
|
22
|
+
source, opts_node = extract_parts(node)
|
|
23
|
+
return [] unless source
|
|
24
|
+
|
|
25
|
+
transformations(source).filter_map do |new_source, description|
|
|
26
|
+
build_regex_mutant(node, opts_node, new_source, source, description, subject:)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def extract_parts(node)
|
|
33
|
+
str_child = node.children.find { |c| c.is_a?(Parser::AST::Node) && c.type == :str }
|
|
34
|
+
opts_node = node.children.find { |c| c.is_a?(Parser::AST::Node) && c.type == :regopt }
|
|
35
|
+
return nil unless str_child
|
|
36
|
+
|
|
37
|
+
[str_child.children[0], opts_node]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_regex_mutant(node, opts_node, new_source, original, description, subject:) # rubocop:disable Metrics/ParameterLists
|
|
41
|
+
return if new_source == original
|
|
42
|
+
return unless valid_regex?(new_source)
|
|
43
|
+
|
|
44
|
+
children = [Parser::AST::Node.new(:str, [new_source]), opts_node]
|
|
45
|
+
mutated = Parser::AST::Node.new(:regexp, children)
|
|
46
|
+
build_mutant(subject:, original_node: node, mutated_node: mutated, description:)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def transformations(source)
|
|
50
|
+
[
|
|
51
|
+
*quantifier_swaps(source),
|
|
52
|
+
*anchor_removals(source),
|
|
53
|
+
*char_class_negations(source)
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def quantifier_swaps(source)
|
|
58
|
+
# Quantifier swaps are symmetric. The other transforms are intentionally
|
|
59
|
+
# one-way because they model reductions instead of reversible edits.
|
|
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
|
+
]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def char_class_negations(source)
|
|
76
|
+
# Negation only flips a positive class into a negated one. We do not
|
|
77
|
+
# emit the reverse mutation because that would mirror the same edit.
|
|
78
|
+
[[source.gsub(/\[(?!\^)/, "[^"), "negated character class"]]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def valid_regex?(source)
|
|
82
|
+
Regexp.new(source)
|
|
83
|
+
true
|
|
84
|
+
rescue RegexpError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser_current"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Operators
|
|
7
|
+
# Removes unary prefix operators by replacing the send node with its receiver.
|
|
8
|
+
#
|
|
9
|
+
# Covers :-@ (unary minus) and :~ (bitwise NOT).
|
|
10
|
+
# Unary negation (!) is intentionally excluded — BooleanLiteral owns that.
|
|
11
|
+
class UnaryOperator < Henitai::Operator
|
|
12
|
+
NODE_TYPES = %i[send].freeze
|
|
13
|
+
UNARY_METHODS = %i[-@ ~].freeze
|
|
14
|
+
|
|
15
|
+
def self.node_types
|
|
16
|
+
NODE_TYPES
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def mutate(node, subject:)
|
|
20
|
+
receiver, method_name, *arguments = node.children
|
|
21
|
+
return [] unless UNARY_METHODS.include?(method_name)
|
|
22
|
+
return [] unless arguments.empty?
|
|
23
|
+
return [] unless receiver
|
|
24
|
+
|
|
25
|
+
[
|
|
26
|
+
build_mutant(
|
|
27
|
+
subject:,
|
|
28
|
+
original_node: node,
|
|
29
|
+
mutated_node: receiver,
|
|
30
|
+
description: "removed unary #{method_name.to_s.delete('@')}"
|
|
31
|
+
)
|
|
32
|
+
]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser_current"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Operators
|
|
7
|
+
# Swaps compound assignment operators with their inverses.
|
|
8
|
+
#
|
|
9
|
+
# Covers arithmetic pairs (+=/-=, *=/=) via :op_asgn and
|
|
10
|
+
# logical pairs (||=/&&=) via :or_asgn/:and_asgn. Exponent and modulo
|
|
11
|
+
# compound assignments are intentionally excluded: they are not part of the
|
|
12
|
+
# supported swap matrix and are already covered by other operator families
|
|
13
|
+
# when appropriate.
|
|
14
|
+
class UpdateOperator < Henitai::Operator
|
|
15
|
+
NODE_TYPES = %i[op_asgn or_asgn and_asgn].freeze
|
|
16
|
+
ARITHMETIC_SWAPS = {
|
|
17
|
+
:+ => :-,
|
|
18
|
+
:- => :+,
|
|
19
|
+
:* => :/,
|
|
20
|
+
:/ => :*
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.node_types
|
|
24
|
+
NODE_TYPES
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def mutate(node, subject:)
|
|
28
|
+
case node.type
|
|
29
|
+
when :op_asgn
|
|
30
|
+
mutate_arithmetic(node, subject:)
|
|
31
|
+
when :or_asgn
|
|
32
|
+
mutate_logical(node, subject:, from: "||=", to: :and_asgn, to_op: "&&=")
|
|
33
|
+
when :and_asgn
|
|
34
|
+
mutate_logical(node, subject:, from: "&&=", to: :or_asgn, to_op: "||=")
|
|
35
|
+
else
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def mutate_arithmetic(node, subject:)
|
|
43
|
+
target, operator, value = node.children
|
|
44
|
+
replacement = ARITHMETIC_SWAPS[operator]
|
|
45
|
+
return [] unless replacement
|
|
46
|
+
|
|
47
|
+
[
|
|
48
|
+
build_mutant(
|
|
49
|
+
subject:,
|
|
50
|
+
original_node: node,
|
|
51
|
+
mutated_node: Parser::AST::Node.new(:op_asgn, [target, replacement, value]),
|
|
52
|
+
description: "replaced #{operator}= with #{replacement}="
|
|
53
|
+
)
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def mutate_logical(node, subject:, from:, to:, to_op:)
|
|
58
|
+
target, value = node.children
|
|
59
|
+
[
|
|
60
|
+
build_mutant(
|
|
61
|
+
subject:,
|
|
62
|
+
original_node: node,
|
|
63
|
+
mutated_node: Parser::AST::Node.new(to, [target, value]),
|
|
64
|
+
description: "replaced #{from} with #{to_op}"
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/henitai/operators.rb
CHANGED
|
@@ -21,5 +21,9 @@ module Henitai
|
|
|
21
21
|
autoload :BlockStatement, "henitai/operators/block_statement"
|
|
22
22
|
autoload :MethodExpression, "henitai/operators/method_expression"
|
|
23
23
|
autoload :AssignmentExpression, "henitai/operators/assignment_expression"
|
|
24
|
+
autoload :MethodChainUnwrap, "henitai/operators/method_chain_unwrap"
|
|
25
|
+
autoload :RegexMutator, "henitai/operators/regex_mutator"
|
|
26
|
+
autoload :UnaryOperator, "henitai/operators/unary_operator"
|
|
27
|
+
autoload :UpdateOperator, "henitai/operators/update_operator"
|
|
24
28
|
end
|
|
25
29
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Runs pending mutants across worker threads with signal and stdin handling.
|
|
5
|
+
class ParallelExecutionRunner
|
|
6
|
+
ParallelExecutionContext = Struct.new(
|
|
7
|
+
:queue, :integration, :config, :progress_reporter,
|
|
8
|
+
:mutex, :state, :old_handlers, :stdin_watcher
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
def initialize(worker_count:)
|
|
12
|
+
@worker_count = worker_count
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(mutants, integration, config, progress_reporter, options = {})
|
|
16
|
+
context = build_parallel_context(
|
|
17
|
+
mutants,
|
|
18
|
+
integration,
|
|
19
|
+
config,
|
|
20
|
+
progress_reporter
|
|
21
|
+
)
|
|
22
|
+
execute_parallel_execution(
|
|
23
|
+
context,
|
|
24
|
+
stdin_pipe: options.fetch(:stdin_pipe, false),
|
|
25
|
+
process_mutant: options.fetch(:process_mutant)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def execute_parallel_execution(context, stdin_pipe:, process_mutant:)
|
|
30
|
+
install_parallel_signal_traps(context)
|
|
31
|
+
start_parallel_stdin_watcher(context, stdin_pipe)
|
|
32
|
+
parallel_workers(context, process_mutant).each(&:join)
|
|
33
|
+
ensure
|
|
34
|
+
stop_parallel_stdin_watcher(context)
|
|
35
|
+
restore_parallel_signal_traps(context)
|
|
36
|
+
raise context.state[:error] if context&.state&.fetch(:error, nil)
|
|
37
|
+
raise Interrupt if context&.state&.fetch(:stopping, false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :worker_count
|
|
43
|
+
|
|
44
|
+
def build_parallel_queue(mutants)
|
|
45
|
+
Queue.new.tap { |queue| mutants.each { |mutant| queue << mutant } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_parallel_context(mutants, integration, config, progress_reporter)
|
|
49
|
+
ParallelExecutionContext.new(
|
|
50
|
+
build_parallel_queue(mutants),
|
|
51
|
+
integration,
|
|
52
|
+
config,
|
|
53
|
+
progress_reporter,
|
|
54
|
+
Mutex.new,
|
|
55
|
+
{ stopping: false }
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def install_parallel_signal_traps(context)
|
|
60
|
+
context.old_handlers = {
|
|
61
|
+
int: trap(:INT) { stop_parallel_execution(context) },
|
|
62
|
+
term: trap(:TERM) { stop_parallel_execution(context) },
|
|
63
|
+
hup: trap(:HUP) { stop_parallel_execution(context) }
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stop_parallel_execution(context)
|
|
68
|
+
context.state[:stopping] = true
|
|
69
|
+
context.queue.clear
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def start_parallel_stdin_watcher(context, stdin_pipe)
|
|
73
|
+
return unless stdin_pipe
|
|
74
|
+
# CI runners expose stdin as a non-interactive pipe, so EOF there should
|
|
75
|
+
# not be treated as a user disconnect.
|
|
76
|
+
return if ci_environment?
|
|
77
|
+
|
|
78
|
+
context.stdin_watcher = Thread.new do
|
|
79
|
+
$stdin.read
|
|
80
|
+
stop_parallel_execution(context)
|
|
81
|
+
rescue IOError, Errno::EBADF
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parallel_workers(context, process_mutant)
|
|
87
|
+
Array.new(worker_count) { Thread.new { process_parallel_worker(context, process_mutant) } }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def process_parallel_worker(context, process_mutant)
|
|
91
|
+
loop do
|
|
92
|
+
break if context.state[:stopping]
|
|
93
|
+
|
|
94
|
+
process_mutant.call(
|
|
95
|
+
context.queue.pop(true),
|
|
96
|
+
context.integration,
|
|
97
|
+
context.config,
|
|
98
|
+
context.progress_reporter,
|
|
99
|
+
context.mutex
|
|
100
|
+
)
|
|
101
|
+
rescue ThreadError
|
|
102
|
+
break
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
record_parallel_error(context, e)
|
|
105
|
+
break
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stop_parallel_stdin_watcher(context)
|
|
110
|
+
context&.stdin_watcher&.kill
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def restore_parallel_signal_traps(context)
|
|
114
|
+
handlers = context&.old_handlers
|
|
115
|
+
return unless handlers
|
|
116
|
+
|
|
117
|
+
trap(:INT, handlers[:int] || "DEFAULT")
|
|
118
|
+
trap(:TERM, handlers[:term] || "DEFAULT")
|
|
119
|
+
trap(:HUP, handlers[:hup] || "DEFAULT")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def record_parallel_error(context, error)
|
|
123
|
+
context.mutex.synchronize do
|
|
124
|
+
context.state[:error] ||= error
|
|
125
|
+
context.state[:stopping] = true
|
|
126
|
+
context.queue.clear
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ci_environment?
|
|
131
|
+
value = ENV.fetch("CI", nil)
|
|
132
|
+
value && !%w[0 false].include?(value.downcase)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Narrows candidate test files using the per-test coverage report.
|
|
5
|
+
class PerTestCoverageSelector
|
|
6
|
+
def initialize(coverage_report_reader: CoverageReportReader.new)
|
|
7
|
+
@coverage_report_reader = coverage_report_reader
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def filter(tests, mutant, reports_dir:)
|
|
11
|
+
candidates = Array(tests)
|
|
12
|
+
return candidates if candidates.empty?
|
|
13
|
+
return candidates unless location_available?(mutant)
|
|
14
|
+
return candidates unless per_test_coverage_available?(reports_dir)
|
|
15
|
+
|
|
16
|
+
covered_tests = candidates.select do |test|
|
|
17
|
+
covers_mutant?(test, mutant, reports_dir)
|
|
18
|
+
end
|
|
19
|
+
covered_tests.empty? ? candidates : covered_tests
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def location_available?(mutant)
|
|
25
|
+
mutant.respond_to?(:location) &&
|
|
26
|
+
mutant.location.is_a?(Hash) &&
|
|
27
|
+
mutant.location[:file] &&
|
|
28
|
+
mutant.location[:start_line] &&
|
|
29
|
+
mutant.location[:end_line]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def covers_mutant?(test, mutant, reports_dir)
|
|
33
|
+
covered_lines = coverage_lines_for(test, mutant, reports_dir)
|
|
34
|
+
mutant_lines(mutant).any? { |line| covered_lines.include?(line) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def coverage_lines_for(test, mutant, reports_dir)
|
|
38
|
+
source_map = per_test_coverage(reports_dir)[test.to_s] || {}
|
|
39
|
+
Array(source_map[File.expand_path(mutant.location[:file])]).uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def mutant_lines(mutant)
|
|
43
|
+
(mutant.location[:start_line]..mutant.location[:end_line]).to_a
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def per_test_coverage(reports_dir)
|
|
47
|
+
@per_test_coverage ||= {}
|
|
48
|
+
@per_test_coverage[reports_dir] ||= begin
|
|
49
|
+
path = File.join(reports_dir, "henitai_per_test.json")
|
|
50
|
+
coverage_report_reader.test_lines_by_file(path)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def per_test_coverage_available?(reports_dir)
|
|
55
|
+
!per_test_coverage(reports_dir).empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
attr_reader :coverage_report_reader
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/henitai/result.rb
CHANGED
|
@@ -11,13 +11,15 @@ module Henitai
|
|
|
11
11
|
include UnparseHelper
|
|
12
12
|
|
|
13
13
|
SCHEMA_VERSION = "1.0"
|
|
14
|
+
DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
14
15
|
|
|
15
|
-
attr_reader :mutants, :started_at, :finished_at
|
|
16
|
+
attr_reader :mutants, :started_at, :finished_at, :thresholds
|
|
16
17
|
|
|
17
|
-
def initialize(mutants:, started_at:, finished_at:)
|
|
18
|
+
def initialize(mutants:, started_at:, finished_at:, thresholds: nil)
|
|
18
19
|
@mutants = mutants
|
|
19
20
|
@started_at = started_at
|
|
20
21
|
@finished_at = finished_at
|
|
22
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
# @return [Integer] number of killed mutants
|
|
@@ -88,7 +90,7 @@ module Henitai
|
|
|
88
90
|
def to_stryker_schema
|
|
89
91
|
{
|
|
90
92
|
schemaVersion: SCHEMA_VERSION,
|
|
91
|
-
thresholds:
|
|
93
|
+
thresholds: thresholds,
|
|
92
94
|
files: build_files_section
|
|
93
95
|
}
|
|
94
96
|
end
|
|
@@ -119,7 +121,17 @@ module Henitai
|
|
|
119
121
|
status: stryker_status(mutant.status),
|
|
120
122
|
description: mutant.description,
|
|
121
123
|
duration: duration_for(mutant)
|
|
122
|
-
}.compact
|
|
124
|
+
}.compact.merge(coverage_schema(mutant))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def coverage_schema(mutant)
|
|
128
|
+
covered_by = Array(mutant.covered_by).compact
|
|
129
|
+
return {} if covered_by.empty?
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
coveredBy: covered_by,
|
|
133
|
+
testsCompleted: mutant.tests_completed || covered_by.size
|
|
134
|
+
}
|
|
123
135
|
end
|
|
124
136
|
|
|
125
137
|
def replacement_for(mutant)
|
data/lib/henitai/runner.rb
CHANGED
|
@@ -35,18 +35,24 @@ module Henitai
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Entry point — runs the full pipeline and returns a Result.
|
|
38
|
+
#
|
|
39
|
+
# Coverage bootstrap (Gate 0) runs in a background thread so that Gate 1
|
|
40
|
+
# (subject resolution) and Gate 2 (mutant generation) proceed concurrently.
|
|
41
|
+
# The thread is joined before Gate 3 (static filtering), which is the first
|
|
42
|
+
# phase that requires coverage data.
|
|
43
|
+
#
|
|
44
|
+
# For targeted runs (`subjects:` provided), the bootstrap is further scoped
|
|
45
|
+
# to the spec files that cover the requested subjects rather than the full
|
|
46
|
+
# suite, reducing the baseline run time proportionally.
|
|
47
|
+
#
|
|
38
48
|
# @return [Result]
|
|
39
49
|
def run
|
|
40
50
|
started_at = Time.now
|
|
41
51
|
source_files = self.source_files
|
|
42
|
-
bootstrap_coverage(source_files)
|
|
43
52
|
subjects = resolve_subjects(source_files)
|
|
44
|
-
mutants =
|
|
45
|
-
mutants = filter_mutants(mutants)
|
|
46
|
-
mutants = execute_mutants(mutants)
|
|
47
|
-
finished_at = Time.now
|
|
53
|
+
mutants = execute_mutants(mutants_for(subjects, source_files))
|
|
48
54
|
|
|
49
|
-
build_result(mutants, started_at,
|
|
55
|
+
build_result(mutants, started_at, Time.now)
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
private
|
|
@@ -69,6 +75,29 @@ module Henitai
|
|
|
69
75
|
static_filter.apply(mutants, config)
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
def mutants_for(subjects, source_files)
|
|
79
|
+
bootstrap_thread = bootstrap_mutants(source_files, subjects)
|
|
80
|
+
mutants = generate_mutants(subjects)
|
|
81
|
+
bootstrap_thread.value
|
|
82
|
+
|
|
83
|
+
filtered_mutants = filter_mutants(mutants)
|
|
84
|
+
return filtered_mutants unless targeted_run?
|
|
85
|
+
|
|
86
|
+
refresh_coverage_for_targeted_run(filtered_mutants, source_files)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def refresh_coverage_for_targeted_run(mutants, source_files)
|
|
90
|
+
return mutants unless retry_full_bootstrap?(mutants)
|
|
91
|
+
|
|
92
|
+
bootstrap_coverage(source_files)
|
|
93
|
+
filter_mutants(mutants)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def bootstrap_mutants(source_files, subjects)
|
|
97
|
+
scoped_tests = scoped_bootstrap_test_files(subjects)
|
|
98
|
+
Thread.new { bootstrap_coverage(source_files, scoped_tests) }
|
|
99
|
+
end
|
|
100
|
+
|
|
72
101
|
def execute_mutants(mutants)
|
|
73
102
|
execution_engine.run(
|
|
74
103
|
mutants,
|
|
@@ -94,13 +123,33 @@ module Henitai
|
|
|
94
123
|
@result = Result.new(
|
|
95
124
|
mutants:,
|
|
96
125
|
started_at:,
|
|
97
|
-
finished_at
|
|
126
|
+
finished_at:,
|
|
127
|
+
thresholds: result_thresholds
|
|
98
128
|
)
|
|
99
129
|
persist_history(@result, finished_at)
|
|
100
130
|
report(@result)
|
|
101
131
|
@result
|
|
102
132
|
end
|
|
103
133
|
|
|
134
|
+
# Returns the spec files to use for the coverage bootstrap.
|
|
135
|
+
#
|
|
136
|
+
# For full runs (no subject pattern given), returns nil so the bootstrapper
|
|
137
|
+
# falls back to the integration's full test-file list.
|
|
138
|
+
#
|
|
139
|
+
# For targeted runs, returns the union of test files selected for each
|
|
140
|
+
# resolved subject. Falls back to nil (all tests) if the selection is empty,
|
|
141
|
+
# so the bootstrapper always has a non-empty file list.
|
|
142
|
+
def scoped_bootstrap_test_files(subjects)
|
|
143
|
+
return nil if pattern_subjects.empty?
|
|
144
|
+
|
|
145
|
+
files = subjects.flat_map { |subject| integration.select_tests(subject) }.uniq
|
|
146
|
+
files.empty? ? nil : files
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def bootstrap_coverage(source_files, test_files = nil)
|
|
150
|
+
coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
|
|
151
|
+
end
|
|
152
|
+
|
|
104
153
|
def subject_resolver
|
|
105
154
|
@subject_resolver ||= SubjectResolver.new
|
|
106
155
|
end
|
|
@@ -125,10 +174,6 @@ module Henitai
|
|
|
125
174
|
@coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
126
175
|
end
|
|
127
176
|
|
|
128
|
-
def bootstrap_coverage(source_files)
|
|
129
|
-
coverage_bootstrapper.ensure!(source_files:, config:, integration:)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
177
|
def integration
|
|
133
178
|
@integration ||= Integration.for(config.integration).new
|
|
134
179
|
end
|
|
@@ -172,6 +217,19 @@ module Henitai
|
|
|
172
217
|
Array(@subjects)
|
|
173
218
|
end
|
|
174
219
|
|
|
220
|
+
def targeted_run?
|
|
221
|
+
!pattern_subjects.empty?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def retry_full_bootstrap?(mutants)
|
|
225
|
+
executable_mutants = Array(mutants).reject do |mutant|
|
|
226
|
+
%i[ignored compile_error equivalent].include?(mutant.status)
|
|
227
|
+
end
|
|
228
|
+
return false if executable_mutants.empty?
|
|
229
|
+
|
|
230
|
+
executable_mutants.all? { |mutant| mutant.status == :no_coverage }
|
|
231
|
+
end
|
|
232
|
+
|
|
175
233
|
def unique_subjects(subjects)
|
|
176
234
|
subjects.uniq { |subject| [subject.expression, subject.source_file] }
|
|
177
235
|
end
|
|
@@ -179,5 +237,11 @@ module Henitai
|
|
|
179
237
|
def normalize_path(path)
|
|
180
238
|
File.expand_path(path)
|
|
181
239
|
end
|
|
240
|
+
|
|
241
|
+
def result_thresholds
|
|
242
|
+
return nil unless config.respond_to?(:thresholds)
|
|
243
|
+
|
|
244
|
+
config.thresholds
|
|
245
|
+
end
|
|
182
246
|
end
|
|
183
247
|
end
|