henitai 0.1.2 → 0.1.4
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 +105 -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 +128 -10
- 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 +195 -110
- 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/string_literal.rb +2 -1
- 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/reporter.rb +14 -2
- data/lib/henitai/result.rb +16 -4
- data/lib/henitai/runner.rb +75 -11
- data/lib/henitai/scenario_execution_result.rb +31 -2
- 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 +66 -10
- metadata +17 -4
|
@@ -34,7 +34,8 @@ module Henitai
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def generate_for_subject(subject, operators, config:, arid_node_filter:, syntax_validator:)
|
|
37
|
-
|
|
37
|
+
source_node = source_node_for(subject)
|
|
38
|
+
return [] unless source_node
|
|
38
39
|
|
|
39
40
|
visitor = SubjectVisitor.new(
|
|
40
41
|
subject,
|
|
@@ -43,11 +44,8 @@ module Henitai
|
|
|
43
44
|
arid_node_filter:,
|
|
44
45
|
syntax_validator:
|
|
45
46
|
)
|
|
46
|
-
visitor.process(
|
|
47
|
-
|
|
48
|
-
visitor.mutants,
|
|
49
|
-
max_mutants_per_line: config&.max_mutants_per_line || 1
|
|
50
|
-
)
|
|
47
|
+
visitor.process(source_node)
|
|
48
|
+
visitor.mutants
|
|
51
49
|
end
|
|
52
50
|
|
|
53
51
|
# Depth-first pre-order AST visitor for a single subject.
|
|
@@ -60,13 +58,8 @@ module Henitai
|
|
|
60
58
|
@mutants = []
|
|
61
59
|
@arid_node_filter = arid_node_filter
|
|
62
60
|
@syntax_validator = syntax_validator
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
) do |operator, map|
|
|
66
|
-
operator.class.node_types.each do |node_type|
|
|
67
|
-
map[node_type] << operator
|
|
68
|
-
end
|
|
69
|
-
end
|
|
61
|
+
initialize_subject_range(subject)
|
|
62
|
+
@operators_by_node_type = index_operators(operators)
|
|
70
63
|
end
|
|
71
64
|
|
|
72
65
|
def process(node)
|
|
@@ -95,27 +88,28 @@ module Henitai
|
|
|
95
88
|
end
|
|
96
89
|
|
|
97
90
|
def node_within_subject_range?(node)
|
|
91
|
+
return true unless @subject_range_begin
|
|
92
|
+
|
|
98
93
|
location = node.location&.expression
|
|
99
|
-
return true unless location
|
|
94
|
+
return true unless location
|
|
100
95
|
|
|
101
|
-
|
|
102
|
-
ranges_overlap?(node_range, @subject.source_range)
|
|
96
|
+
location.line <= @subject_range_end && @subject_range_begin <= location.last_line
|
|
103
97
|
end
|
|
104
98
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
end
|
|
99
|
+
def initialize_subject_range(subject)
|
|
100
|
+
subject_range = subject.source_range
|
|
101
|
+
return unless subject_range
|
|
109
102
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
key = line_key(mutant)
|
|
113
|
-
selected[key] ||= []
|
|
114
|
-
selected[key] << mutant
|
|
103
|
+
@subject_range_begin = subject_range.begin
|
|
104
|
+
@subject_range_end = subject_range.end
|
|
115
105
|
end
|
|
116
106
|
|
|
117
|
-
|
|
118
|
-
|
|
107
|
+
def index_operators(operators)
|
|
108
|
+
operators.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |operator, map|
|
|
109
|
+
operator.class.node_types.each do |node_type|
|
|
110
|
+
map[node_type] << operator
|
|
111
|
+
end
|
|
112
|
+
end
|
|
119
113
|
end
|
|
120
114
|
end
|
|
121
115
|
|
|
@@ -131,28 +125,11 @@ module Henitai
|
|
|
131
125
|
)
|
|
132
126
|
end
|
|
133
127
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
mutant.location[:start_line]
|
|
138
|
-
]
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def mutant_priority_key(mutant)
|
|
142
|
-
[
|
|
143
|
-
operator_priority(mutant.operator),
|
|
144
|
-
mutant.location[:start_col] || 0,
|
|
145
|
-
mutant.description
|
|
146
|
-
]
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def operator_priority(operator_name)
|
|
150
|
-
operator_priority_map.fetch(operator_name, operator_priority_map.length)
|
|
151
|
-
end
|
|
128
|
+
def source_node_for(subject)
|
|
129
|
+
return subject.ast_node if subject.ast_node
|
|
130
|
+
return nil unless subject.source_file && subject.source_range
|
|
152
131
|
|
|
153
|
-
|
|
154
|
-
# The constant order defines signal priority for per-line pruning.
|
|
155
|
-
@operator_priority_map ||= Operator::FULL_SET.each_with_index.to_h
|
|
132
|
+
SourceParser.new.parse_file(subject.source_file)
|
|
156
133
|
end
|
|
157
134
|
end
|
|
158
135
|
end
|
data/lib/henitai/operator.rb
CHANGED
|
@@ -13,7 +13,8 @@ module Henitai
|
|
|
13
13
|
#
|
|
14
14
|
# Additional operators (full set):
|
|
15
15
|
# ArrayDeclaration, HashLiteral, RangeLiteral, SafeNavigation,
|
|
16
|
-
# PatternMatch, BlockStatement, MethodExpression, AssignmentExpression
|
|
16
|
+
# PatternMatch, BlockStatement, MethodExpression, AssignmentExpression,
|
|
17
|
+
# UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap
|
|
17
18
|
#
|
|
18
19
|
# Each operator subclass must implement:
|
|
19
20
|
# - .node_types → Array<Symbol> AST node types this operator handles
|
|
@@ -32,12 +33,16 @@ module Henitai
|
|
|
32
33
|
FULL_SET = (LIGHT_SET + %w[
|
|
33
34
|
ArrayDeclaration
|
|
34
35
|
HashLiteral
|
|
36
|
+
MethodChainUnwrap
|
|
35
37
|
RangeLiteral
|
|
38
|
+
RegexMutator
|
|
36
39
|
SafeNavigation
|
|
37
40
|
PatternMatch
|
|
38
41
|
BlockStatement
|
|
39
42
|
MethodExpression
|
|
40
43
|
AssignmentExpression
|
|
44
|
+
UnaryOperator
|
|
45
|
+
UpdateOperator
|
|
41
46
|
]).freeze
|
|
42
47
|
|
|
43
48
|
# @param set [Symbol] :light or :full
|
|
@@ -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
|
|
@@ -27,7 +27,8 @@ module Henitai
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
29
|
def mutate_plain_string(node, subject:)
|
|
30
|
-
|
|
30
|
+
original_value = node.children.first
|
|
31
|
+
REPLACEMENTS.reject { |r| r == original_value }.map do |replacement|
|
|
31
32
|
build_mutant(
|
|
32
33
|
subject:,
|
|
33
34
|
original_node: node,
|
|
@@ -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/reporter.rb
CHANGED
|
@@ -126,11 +126,23 @@ module Henitai
|
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
def original_line(mutant)
|
|
129
|
-
format("- %s",
|
|
129
|
+
format("- %s", display_unparse(mutant.original_node))
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
def mutated_line(mutant)
|
|
133
|
-
format("+ %s",
|
|
133
|
+
format("+ %s", display_unparse(mutant.mutated_node))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Like safe_unparse but makes invisible characters visible in terminal
|
|
137
|
+
# output. For string literal nodes the inner value is shown via #inspect
|
|
138
|
+
# so that e.g. "" vs " " vs "\n" are unambiguous. Other nodes unparse
|
|
139
|
+
# normally.
|
|
140
|
+
def display_unparse(node)
|
|
141
|
+
if node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
|
|
142
|
+
node.children.first.inspect
|
|
143
|
+
else
|
|
144
|
+
safe_unparse(node)
|
|
145
|
+
end
|
|
134
146
|
end
|
|
135
147
|
|
|
136
148
|
def score_line(result)
|