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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +3 -1
  4. data/assets/schema/henitai.schema.json +0 -4
  5. data/lib/henitai/arid_node_filter.rb +3 -0
  6. data/lib/henitai/available_cpu_count.rb +79 -0
  7. data/lib/henitai/cli.rb +7 -4
  8. data/lib/henitai/configuration.rb +3 -5
  9. data/lib/henitai/configuration_validator.rb +1 -11
  10. data/lib/henitai/coverage_bootstrapper.rb +112 -8
  11. data/lib/henitai/coverage_formatter.rb +4 -4
  12. data/lib/henitai/coverage_report_reader.rb +67 -0
  13. data/lib/henitai/eager_load.rb +11 -0
  14. data/lib/henitai/equivalence_detector.rb +60 -1
  15. data/lib/henitai/execution_engine.rb +34 -22
  16. data/lib/henitai/integration/rspec_process_runner.rb +58 -0
  17. data/lib/henitai/integration.rb +222 -100
  18. data/lib/henitai/minitest_simplecov.rb +3 -0
  19. data/lib/henitai/mutant/activator.rb +78 -42
  20. data/lib/henitai/mutant.rb +3 -1
  21. data/lib/henitai/mutant_generator.rb +25 -48
  22. data/lib/henitai/operator.rb +6 -1
  23. data/lib/henitai/operators/assignment_expression.rb +7 -23
  24. data/lib/henitai/operators/conditional_expression.rb +1 -7
  25. data/lib/henitai/operators/method_chain_unwrap.rb +41 -0
  26. data/lib/henitai/operators/regex_mutator.rb +89 -0
  27. data/lib/henitai/operators/unary_operator.rb +36 -0
  28. data/lib/henitai/operators/update_operator.rb +70 -0
  29. data/lib/henitai/operators.rb +4 -0
  30. data/lib/henitai/parallel_execution_runner.rb +135 -0
  31. data/lib/henitai/per_test_coverage_selector.rb +60 -0
  32. data/lib/henitai/result.rb +16 -4
  33. data/lib/henitai/rspec_coverage_formatter.rb +10 -0
  34. data/lib/henitai/runner.rb +75 -11
  35. data/lib/henitai/source_parser.rb +12 -1
  36. data/lib/henitai/static_filter.rb +53 -38
  37. data/lib/henitai/version.rb +1 -1
  38. data/lib/henitai.rb +3 -0
  39. data/sig/henitai.rbs +65 -11
  40. 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
- target_for(subject).class_eval(method_source(mutant), __FILE__, __LINE__ + 1)
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
- mutated_subject = replace_node(
61
- subject_node,
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
- updated_children = node.children.map do |child|
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 same_node?(left, right)
83
- left_location = node_location_signature(left)
84
- right_location = node_location_signature(right)
85
- return left.equal?(right) unless left_location && right_location
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
- left_location == right_location
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 replace_child(child, original_node, mutated_node)
91
- case child
92
- when Parser::AST::Node
93
- replace_node(child, original_node, mutated_node)
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
- child
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 node_location_signature(node)
215
- expression = node&.location&.expression
216
- return unless expression
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)
@@ -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
- return [] unless subject.source_file && subject.source_range
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(SourceParser.parse_file(subject.source_file))
47
- prune_mutants_per_line(
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
- @operators_by_node_type = operators.each_with_object(
64
- Hash.new { |hash, key| hash[key] = [] }
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 && @subject.source_range
94
+ return true unless location
100
95
 
101
- node_range = location.line..location.last_line
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 ranges_overlap?(left, right)
106
- left.begin <= right.end && right.begin <= left.end
107
- end
108
- end
99
+ def initialize_subject_range(subject)
100
+ subject_range = subject.source_range
101
+ return unless subject_range
109
102
 
110
- def prune_mutants_per_line(mutants, max_mutants_per_line:)
111
- grouped = mutants.each_with_object({}) do |mutant, selected|
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
- grouped.values.flat_map do |mutants_for_line|
118
- mutants_for_line.sort_by { |mutant| mutant_priority_key(mutant) }.take(max_mutants_per_line)
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 line_key(mutant)
135
- [
136
- mutant.location[:file],
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
- def operator_priority_map
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
@@ -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
- # Mutates compound assignments and reduces ||= to a plain assignment.
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[op_asgn or_asgn].freeze
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
- return [[], nil] if children.empty?
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
@@ -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