henitai 0.1.1 → 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 +91 -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 -8
- data/lib/henitai/coverage_formatter.rb +4 -4
- 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 +222 -100
- data/lib/henitai/minitest_simplecov.rb +3 -0
- data/lib/henitai/mutant/activator.rb +78 -42
- 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/rspec_coverage_formatter.rb +10 -0
- data/lib/henitai/runner.rb +75 -11
- data/lib/henitai/source_parser.rb +12 -1
- data/lib/henitai/static_filter.rb +53 -38
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +3 -0
- data/sig/henitai.rbs +65 -11
- metadata +18 -4
|
@@ -7,6 +7,22 @@ module Henitai
|
|
|
7
7
|
class Mutant
|
|
8
8
|
# Activates a mutant inside the forked child process.
|
|
9
9
|
class Activator
|
|
10
|
+
# Filters "already initialized constant" C-level warnings that fire when
|
|
11
|
+
# a source file is loaded into a process that already has the constant
|
|
12
|
+
# defined via require. Uses a thread-local flag so the filter is active
|
|
13
|
+
# only during load_source_file, leaving all other warnings untouched.
|
|
14
|
+
module ConstantRedefinitionFilter
|
|
15
|
+
PATTERN = /already initialized constant|previous definition of/
|
|
16
|
+
private_constant :PATTERN
|
|
17
|
+
|
|
18
|
+
def warn(msg, **kwargs)
|
|
19
|
+
return if Thread.current[:henitai_filter_const_warnings] && PATTERN.match?(msg.to_s)
|
|
20
|
+
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
Warning.singleton_class.prepend(ConstantRedefinitionFilter)
|
|
25
|
+
|
|
10
26
|
SERIALIZER_METHODS = {
|
|
11
27
|
arg: :argument_parameter_fragment,
|
|
12
28
|
optarg: :optional_parameter_fragment,
|
|
@@ -26,8 +42,9 @@ module Henitai
|
|
|
26
42
|
subject = mutant.subject
|
|
27
43
|
raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
|
|
28
44
|
|
|
45
|
+
target = target_for(subject)
|
|
29
46
|
Henitai::WarningSilencer.silence do
|
|
30
|
-
|
|
47
|
+
target.class_eval(method_source(mutant), __FILE__, __LINE__ + 1)
|
|
31
48
|
nil
|
|
32
49
|
end
|
|
33
50
|
rescue Unparser::UnsupportedNodeError
|
|
@@ -57,44 +74,28 @@ module Henitai
|
|
|
57
74
|
subject_node = mutant.subject.ast_node
|
|
58
75
|
return compile_safe_unparse(mutant.mutated_node) unless subject_node
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
mutant.original_node,
|
|
63
|
-
mutant.mutated_node
|
|
64
|
-
)
|
|
65
|
-
body = method_body(mutated_subject) || Parser::AST::Node.new(:nil, [])
|
|
66
|
-
compile_safe_unparse(body)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def replace_node(node, original_node, mutated_node)
|
|
70
|
-
return mutated_node if same_node?(node, original_node)
|
|
71
|
-
return node unless node.is_a?(Parser::AST::Node)
|
|
77
|
+
body = method_body(subject_node)
|
|
78
|
+
return compile_safe_unparse(Parser::AST::Node.new(:nil, [])) unless body
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
replace_child(child, original_node, mutated_node)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
return node if updated_children == node.children
|
|
78
|
-
|
|
79
|
-
Parser::AST::Node.new(node.type, updated_children)
|
|
80
|
+
body_source_for_mutant(body, mutant)
|
|
80
81
|
end
|
|
81
82
|
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return
|
|
83
|
+
def body_source_for_mutant(body, mutant)
|
|
84
|
+
original_range = mutant.original_node.location&.expression
|
|
85
|
+
location = body.location
|
|
86
|
+
return source_body(location, body) unless original_range && location
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
replacement = compile_safe_unparse(mutant.mutated_node)
|
|
89
|
+
body_source_for_location(location, original_range, replacement, body)
|
|
88
90
|
end
|
|
89
91
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
when Array
|
|
95
|
-
child.map { |item| replace_child(item, original_node, mutated_node) }
|
|
92
|
+
def body_source_for_location(location, original_range, replacement, body)
|
|
93
|
+
if heredoc_location?(location)
|
|
94
|
+
heredoc_body_source(location, original_range, replacement) ||
|
|
95
|
+
source_body(location, body)
|
|
96
96
|
else
|
|
97
|
-
|
|
97
|
+
expression_source(location, original_range, replacement) ||
|
|
98
|
+
source_body(location, body)
|
|
98
99
|
end
|
|
99
100
|
end
|
|
100
101
|
|
|
@@ -184,6 +185,38 @@ module Henitai
|
|
|
184
185
|
subject_node.children[1]
|
|
185
186
|
end
|
|
186
187
|
|
|
188
|
+
def heredoc_location?(location)
|
|
189
|
+
location.respond_to?(:heredoc_body) && location.heredoc_body
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def heredoc_body_source(location, original_range, replacement)
|
|
193
|
+
body_source = replace_source_fragment(
|
|
194
|
+
location.heredoc_body,
|
|
195
|
+
original_range,
|
|
196
|
+
replacement
|
|
197
|
+
)
|
|
198
|
+
return unless body_source
|
|
199
|
+
|
|
200
|
+
"#{location.expression.source}\n#{body_source}#{location.heredoc_end.source}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def source_body(location, body)
|
|
204
|
+
return compile_safe_unparse(body) unless location
|
|
205
|
+
|
|
206
|
+
if heredoc_location?(location)
|
|
207
|
+
"#{location.expression.source}\n#{location.heredoc_body.source}#{location.heredoc_end.source}"
|
|
208
|
+
else
|
|
209
|
+
location.expression.source
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def expression_source(location, original_range, replacement)
|
|
214
|
+
source_range = location.expression
|
|
215
|
+
return unless source_range
|
|
216
|
+
|
|
217
|
+
replace_source_fragment(source_range, original_range, replacement)
|
|
218
|
+
end
|
|
219
|
+
|
|
187
220
|
def load_target(subject)
|
|
188
221
|
Object.const_get(subject.namespace.delete_prefix("::"))
|
|
189
222
|
rescue NameError
|
|
@@ -195,7 +228,10 @@ module Henitai
|
|
|
195
228
|
source_file = subject.source_file || source_file_from_ast(subject)
|
|
196
229
|
return unless source_file && File.file?(source_file)
|
|
197
230
|
|
|
231
|
+
Thread.current[:henitai_filter_const_warnings] = true
|
|
198
232
|
load(source_file)
|
|
233
|
+
ensure
|
|
234
|
+
Thread.current[:henitai_filter_const_warnings] = false
|
|
199
235
|
end
|
|
200
236
|
|
|
201
237
|
def source_file_from_ast(subject)
|
|
@@ -211,17 +247,17 @@ module Henitai
|
|
|
211
247
|
expression.source_buffer.name
|
|
212
248
|
end
|
|
213
249
|
|
|
214
|
-
def
|
|
215
|
-
|
|
216
|
-
|
|
250
|
+
def replace_source_fragment(source_range, original_range, replacement)
|
|
251
|
+
source = source_range.source
|
|
252
|
+
start = original_range.begin_pos - source_range.begin_pos
|
|
253
|
+
stop = original_range.end_pos - source_range.begin_pos
|
|
254
|
+
return unless start >= 0 && stop <= source.bytesize && start <= stop
|
|
255
|
+
|
|
256
|
+
prefix = source.byteslice(0, start)
|
|
257
|
+
suffix = source.byteslice(stop, source.bytesize - stop)
|
|
258
|
+
return unless prefix && suffix
|
|
217
259
|
|
|
218
|
-
|
|
219
|
-
expression.source_buffer.name,
|
|
220
|
-
expression.line,
|
|
221
|
-
expression.column,
|
|
222
|
-
expression.last_line,
|
|
223
|
-
expression.last_column
|
|
224
|
-
]
|
|
260
|
+
prefix + replacement + suffix
|
|
225
261
|
end
|
|
226
262
|
|
|
227
263
|
def compile_safe_unparse(node)
|
data/lib/henitai/mutant.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Henitai
|
|
|
35
35
|
|
|
36
36
|
attr_reader :id, :subject, :operator, :original_node, :mutated_node,
|
|
37
37
|
:mutation_type, :description, :location
|
|
38
|
-
attr_accessor :status, :killing_test, :duration
|
|
38
|
+
attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed
|
|
39
39
|
|
|
40
40
|
# @param subject [Subject] the subject being mutated
|
|
41
41
|
# @param operator [Symbol] operator name, e.g. :ArithmeticOperator
|
|
@@ -53,6 +53,8 @@ module Henitai
|
|
|
53
53
|
@status = :pending
|
|
54
54
|
@killing_test = nil
|
|
55
55
|
@duration = nil
|
|
56
|
+
@covered_by = nil
|
|
57
|
+
@tests_completed = nil
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def killed? = @status == :killed
|
|
@@ -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
|
|
@@ -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
|