evilution 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +132 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +136 -38
  6. data/docs/architecture.md +135 -11
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +121 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/class_body.rb +101 -0
  13. data/lib/evilution/ast/included_block.rb +41 -0
  14. data/lib/evilution/ast/literal_callable.rb +21 -0
  15. data/lib/evilution/ast/never_nil.rb +36 -0
  16. data/lib/evilution/ast/parser.rb +187 -21
  17. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  18. data/lib/evilution/ast/pattern/parser.rb +14 -15
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/round_half_mode.rb +38 -0
  21. data/lib/evilution/ast/scope_declaration.rb +45 -0
  22. data/lib/evilution/ast/source_surgeon.rb +2 -3
  23. data/lib/evilution/ast/syntax_check.rb +56 -0
  24. data/lib/evilution/ast/uncovered_code.rb +88 -0
  25. data/lib/evilution/ast/value_object_definition.rb +25 -0
  26. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  27. data/lib/evilution/baseline/report.rb +66 -0
  28. data/lib/evilution/baseline/spec_failure.rb +38 -0
  29. data/lib/evilution/baseline.rb +67 -30
  30. data/lib/evilution/cli/exit_guard.rb +26 -4
  31. data/lib/evilution/cli/parser/file_args.rb +2 -1
  32. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  33. data/lib/evilution/cli.rb +3 -2
  34. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  35. data/lib/evilution/config.rb +3 -2
  36. data/lib/evilution/equivalent/detector.rb +3 -1
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  38. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  39. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  40. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  41. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  42. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  43. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  44. data/lib/evilution/example_filter.rb +45 -0
  45. data/lib/evilution/hooks/registry.rb +2 -1
  46. data/lib/evilution/integration/base.rb +11 -1
  47. data/lib/evilution/integration/known_failures.rb +26 -0
  48. data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
  49. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  50. data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
  51. data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
  52. data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
  53. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  54. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  55. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  56. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  57. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  58. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  59. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  60. data/lib/evilution/integration/minitest.rb +45 -6
  61. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  62. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  63. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  64. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  65. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  66. data/lib/evilution/integration/rspec.rb +22 -4
  67. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  68. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  69. data/lib/evilution/integration/test_unit.rb +37 -9
  70. data/lib/evilution/isolation/fork.rb +84 -11
  71. data/lib/evilution/isolation/in_process.rb +100 -8
  72. data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
  73. data/lib/evilution/mcp/info_tool.rb +3 -2
  74. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  75. data/lib/evilution/memory/leak_check.rb +27 -3
  76. data/lib/evilution/mutation.rb +21 -9
  77. data/lib/evilution/mutator/base.rb +69 -5
  78. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  79. data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
  80. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  81. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  82. data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
  83. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  84. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  85. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  86. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  87. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  88. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  89. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  90. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  91. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  92. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  93. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  94. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  95. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  96. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  97. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  98. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  99. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  100. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  101. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  102. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  103. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  104. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  105. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  106. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  107. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  108. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  109. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  110. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  111. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  112. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  113. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  114. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  115. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  116. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  117. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  118. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  119. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  120. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  121. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  122. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  123. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  124. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  125. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  126. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  127. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  128. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  129. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  130. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  131. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  132. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  133. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  134. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  135. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  136. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  137. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  138. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  139. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  140. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  141. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  142. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  143. data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
  144. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  145. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  146. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  147. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  148. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  149. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  150. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  151. data/lib/evilution/mutator/primitives.rb +24 -2
  152. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  153. data/lib/evilution/mutator/registry.rb +58 -2
  154. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  155. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  156. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  157. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  158. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  159. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  160. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  161. data/lib/evilution/parallel/work_queue.rb +15 -9
  162. data/lib/evilution/process_supervisor.rb +3 -2
  163. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  164. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  165. data/lib/evilution/reporter/json/baseline.rb +15 -0
  166. data/lib/evilution/reporter/json.rb +7 -2
  167. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  168. data/lib/evilution/result/mutation_result.rb +9 -1
  169. data/lib/evilution/result/summary.rb +42 -2
  170. data/lib/evilution/runner/baseline_runner.rb +9 -4
  171. data/lib/evilution/runner/canary.rb +2 -57
  172. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  173. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  174. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  175. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  176. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  177. data/lib/evilution/runner/mutation_executor.rb +1 -1
  178. data/lib/evilution/runner/mutation_planner.rb +11 -2
  179. data/lib/evilution/runner/report_publisher.rb +3 -2
  180. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  181. data/lib/evilution/runner/subject_pipeline.rb +57 -43
  182. data/lib/evilution/runner.rb +5 -2
  183. data/lib/evilution/subject.rb +25 -2
  184. data/lib/evilution/version.rb +1 -1
  185. data/lib/evilution.rb +90 -0
  186. data/script/memory_check +62 -16
  187. metadata +113 -4
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap the values of neighbouring keyword arguments, keeping the keys:
6
+ # `compute(x: a, y: b)` becomes `compute(x: b, y: a)`.
7
+ #
8
+ # A survivor means no example tells the two values apart — the keyword form
9
+ # of the defect ArgumentOrderPermutation probes for positional arguments.
10
+ #
11
+ # Only keyword arguments written in the call are swapped: a braced hash is a
12
+ # single positional argument whose pairs are data. A double splat stays in
13
+ # place, and identical values are not swapped, since that would reproduce the
14
+ # original call. A shorthand key (`compute(a:, b:)`) has no value of its own
15
+ # to move, so the swap spells it out: `compute(a: b, b: a)`.
16
+ class Evilution::Mutator::Operator::KeywordValueSwap < Evilution::Mutator::Base
17
+ def visit_call_node(node)
18
+ swap_keyword_values(node)
19
+ super
20
+ end
21
+
22
+ def visit_super_node(node)
23
+ swap_keyword_values(node)
24
+ super
25
+ end
26
+
27
+ def visit_yield_node(node)
28
+ swap_keyword_values(node)
29
+ super
30
+ end
31
+
32
+ private
33
+
34
+ def swap_keyword_values(node)
35
+ return if node.arguments.nil?
36
+
37
+ node.arguments.arguments.grep(Prism::KeywordHashNode).each do |keywords|
38
+ pairs = keywords.elements.grep(Prism::AssocNode)
39
+ pairs.each_cons(2) do |left, right|
40
+ emit_swap(node, left, right) unless value_text(left) == value_text(right)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Whatever separates the two pairs — the comma, a double splat, line breaks,
46
+ # comments — stays between them, so the call keeps its layout.
47
+ def emit_swap(node, left, right)
48
+ start_offset = left.location.start_offset
49
+
50
+ add_mutation(
51
+ offset: start_offset,
52
+ length: right.location.end_offset - start_offset,
53
+ replacement: "#{key_text(left)}#{value_text(right)}#{source_between(left, right)}" \
54
+ "#{key_text(right)}#{value_text(left)}",
55
+ node: node
56
+ )
57
+ end
58
+
59
+ def source_between(left, right)
60
+ byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
61
+ end
62
+
63
+ # The key with everything up to its value: `x: `, `"x" => `.
64
+ def key_text(pair)
65
+ return "#{pair.key.slice} " if pair.value.is_a?(Prism::ImplicitNode)
66
+
67
+ byteslice_source(pair.location.start_offset, pair.value.location.start_offset - pair.location.start_offset)
68
+ end
69
+
70
+ # A shorthand key stands for the local variable or method of the same name.
71
+ def value_text(pair)
72
+ value = pair.value
73
+ value.is_a?(Prism::ImplicitNode) ? value.value.name.to_s : value.slice
74
+ end
75
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Turn a memoizing assignment into a plain one: `@x ||= expr` becomes
6
+ # `@x = expr`, so the value is computed again on every call.
7
+ #
8
+ # A survivor means no example tells one computation from several: nothing
9
+ # counts the calls behind the value, and nothing depends on getting the same
10
+ # object back.
11
+ class Evilution::Mutator::Operator::MemoizationBreak < Evilution::Mutator::Base
12
+ def visit_instance_variable_or_write_node(node)
13
+ operator = node.operator_loc
14
+ add_mutation(offset: operator.start_offset, length: operator.length, replacement: "=", node: node)
15
+
16
+ super
17
+ end
18
+ end
@@ -3,7 +3,15 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../operator"
6
-
6
+ require_relative "../../ast/class_body"
7
+
8
+ # Drop a mixin from a class or module body: `include`, `extend` and `prepend`
9
+ # calls are removed.
10
+ #
11
+ # The calls sit in the class body, outside every method, where no subject
12
+ # reaches them. They are attributed to the method anchoring the enclosing
13
+ # body (see Evilution::AST::ClassBody). A mixin call inside a method is left
14
+ # to the generic call operators.
7
15
  class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
8
16
  MIXIN_METHODS = %i[include extend prepend].freeze
9
17
 
@@ -13,22 +21,22 @@ class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
13
21
  @mutations = []
14
22
  @filter = filter
15
23
 
16
- enclosing = find_target_scope(subject)
17
- return @mutations unless enclosing
18
-
19
- find_mixin_calls(enclosing).each { |call_node| emit_mixin_removal(call_node) }
24
+ mixin_calls_for(subject).each { |call_node| emit_mixin_removal(call_node) }
20
25
  @mutations
21
26
  end
22
27
 
23
28
  private
24
29
 
25
- def find_target_scope(subject)
30
+ def mixin_calls_for(subject)
26
31
  tree = self.class.parsed_tree_for(subject.file_path, @file_source)
27
- enclosing = find_enclosing_scope(tree, subject.line_number)
28
- return nil unless enclosing
29
- return nil unless find_first_method_line(enclosing) == subject.line_number
30
32
 
31
- enclosing
33
+ Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
34
+ body.declarations { |node| mixin_call?(node) }
35
+ end
36
+ end
37
+
38
+ def mixin_call?(node)
39
+ node.is_a?(Prism::CallNode) && MIXIN_METHODS.include?(node.name) && node.receiver.nil?
32
40
  end
33
41
 
34
42
  def emit_mixin_removal(call_node)
@@ -39,49 +47,4 @@ class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
39
47
  node: call_node
40
48
  )
41
49
  end
42
-
43
- def find_enclosing_scope(tree, target_line)
44
- finder = ScopeFinder.new(target_line)
45
- finder.visit(tree)
46
- finder.result
47
- end
48
-
49
- def find_first_method_line(scope_node)
50
- return nil unless scope_node.body
51
-
52
- scope_node.body.body.each do |node|
53
- return node.location.start_line if node.is_a?(Prism::DefNode)
54
- end
55
- nil
56
- end
57
-
58
- def find_mixin_calls(scope_node)
59
- return [] unless scope_node.body
60
-
61
- scope_node.body.body.select do |node|
62
- node.is_a?(Prism::CallNode) &&
63
- MIXIN_METHODS.include?(node.name) &&
64
- node.receiver.nil?
65
- end
66
- end
67
-
68
- # Visitor to find the ClassNode or ModuleNode enclosing a given line number.
69
- class ScopeFinder < Prism::Visitor
70
- attr_reader :result
71
-
72
- def initialize(target_line)
73
- @target_line = target_line
74
- @result = nil
75
- end
76
-
77
- def visit_class_node(node)
78
- @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
79
- super
80
- end
81
-
82
- def visit_module_node(node)
83
- @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
84
- super
85
- end
86
- end
87
50
  end
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::NilReplacement < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  REPLACEMENTS = %w[true false 0 ""].freeze
7
9
 
8
10
  def visit_nil_node(node)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Give a `case/in` that has no `else` an empty one.
6
+ #
7
+ # Without an else, a value no pattern matches raises NoMatchingPatternError;
8
+ # with an empty one the expression quietly yields nil. A survivor means no
9
+ # example feeds the match a value outside its patterns, so nothing asserts
10
+ # that such a value is rejected.
11
+ #
12
+ # The opposite edit, removing an else that is there, belongs to CaseIn.
13
+ class Evilution::Mutator::Operator::NoMatchingPatternElse < Evilution::Mutator::Base
14
+ def visit_case_match_node(node)
15
+ add_else(node) if node.else_clause.nil? && !exhaustive?(node)
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def add_else(node)
22
+ offset = node.end_keyword_loc.start_offset
23
+
24
+ add_mutation(
25
+ offset: offset,
26
+ length: 0,
27
+ replacement: else_clause_before(offset),
28
+ node: node
29
+ )
30
+ end
31
+
32
+ # An `end` on a line of its own gets the else on the line above, indented
33
+ # alike. Otherwise the case is written on one line and the else joins it.
34
+ def else_clause_before(offset)
35
+ line_start = line_start_byte(@file_source, offset)
36
+ indentation = byteslice_source(line_start, offset - line_start)
37
+
38
+ indentation.strip.empty? ? "else\n#{indentation}" : "else; "
39
+ end
40
+
41
+ # A bare name (`in other`, `in _`) matches every value, so an else after it
42
+ # could never run and adding one would change nothing. A guarded capture is
43
+ # not a bare name: Prism wraps it in the guard's conditional.
44
+ def exhaustive?(node)
45
+ node.conditions.any? { |clause| clause.pattern.is_a?(Prism::LocalVariableTargetNode) }
46
+ end
47
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap the numbered parameters of a block: `pairs.map { _1 - _2 }` becomes
6
+ # `pairs.map { _2 - _1 }`.
7
+ #
8
+ # A survivor means no example tells the two values apart, so the block could
9
+ # take them in either order unnoticed.
10
+ #
11
+ # Only parameters the body reads are swapped with each other, each with its
12
+ # neighbour. Swapping in a parameter the body never reads (`{ _1 }` to
13
+ # `{ _2 }`) would change how many values the block takes, which is a different
14
+ # mutation from reordering them.
15
+ class Evilution::Mutator::Operator::NumberedParameterSwap < Evilution::Mutator::Base
16
+ NUMBERED_NAME = /\A_[1-9]\z/
17
+
18
+ def visit_block_node(node)
19
+ swap_parameters(node)
20
+ super
21
+ end
22
+
23
+ def visit_lambda_node(node)
24
+ swap_parameters(node)
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def swap_parameters(node)
31
+ return unless node.parameters.is_a?(Prism::NumberedParametersNode)
32
+
33
+ reads = numbered_reads(node.body)
34
+ names = reads.map { |read| read.name.to_s }.uniq.sort
35
+ names.each_cons(2) { |left, right| emit_swap(node, reads, left, right) }
36
+ end
37
+
38
+ # Every read of a numbered parameter in the body. Blocks nested in it are
39
+ # searched too: a numbered parameter read there can only be this block's,
40
+ # since Ruby rejects a nested block that declares its own. A method defined
41
+ # in the body opens a new scope, whose blocks have parameters of their own.
42
+ def numbered_reads(node)
43
+ return [] if node.is_a?(Prism::DefNode)
44
+ return [node] if node.is_a?(Prism::LocalVariableReadNode) && node.name.to_s.match?(NUMBERED_NAME)
45
+
46
+ node.compact_child_nodes.flat_map { |child| numbered_reads(child) }
47
+ end
48
+
49
+ # The reads are scattered through the body, while a mutation replaces one
50
+ # contiguous range, so the range runs from the first affected read to the
51
+ # last and is rewritten with the two names exchanged.
52
+ def emit_swap(node, reads, left, right)
53
+ affected = reads.select { |read| [left, right].include?(read.name.to_s) }
54
+ .sort_by { |read| read.location.start_offset }
55
+ start_offset = affected.first.location.start_offset
56
+ end_offset = affected.last.location.end_offset
57
+
58
+ add_mutation(
59
+ offset: start_offset,
60
+ length: end_offset - start_offset,
61
+ replacement: exchange(affected, start_offset, end_offset, left => right, right => left),
62
+ node: node
63
+ )
64
+ end
65
+
66
+ def exchange(affected, start_offset, end_offset, renames)
67
+ text = byteslice_source(start_offset, end_offset - start_offset)
68
+
69
+ affected.reverse_each do |read|
70
+ location = read.location
71
+ text = text.byteslice(0, location.start_offset - start_offset) +
72
+ renames.fetch(read.name.to_s) +
73
+ text.byteslice((location.end_offset - start_offset)..)
74
+ end
75
+
76
+ text
77
+ end
78
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Shift a count held in a variable down by one: `n.times` becomes
6
+ # `(n - 1).times`, `items.first(n)` becomes `items.first(n - 1)`.
7
+ #
8
+ # A survivor means no example checks the last iteration or the last element,
9
+ # the classic fencepost slip. A literal count is left to IntegerLiteral, which
10
+ # already shifts it; this operator reaches the counts that have no literal.
11
+ class Evilution::Mutator::Operator::OffByOneBoundary < Evilution::Mutator::Base
12
+ # Methods whose receiver is the count.
13
+ RECEIVER_COUNT_METHODS = %i[times].freeze
14
+
15
+ # Methods whose single argument is the count or the bound.
16
+ ARGUMENT_COUNT_METHODS = %i[upto downto take first last drop each_slice each_cons].freeze
17
+
18
+ # Arguments that stand in the list without being a value: a splat,
19
+ # keywords, forwarded arguments. Subtracting from them would not parse.
20
+ NON_VALUE_ARGUMENT_TYPES = [
21
+ Prism::SplatNode, Prism::KeywordHashNode, Prism::ForwardingArgumentsNode
22
+ ].freeze
23
+
24
+ def visit_call_node(node)
25
+ shift_count(node) unless node.safe_navigation?
26
+ super
27
+ end
28
+
29
+ private
30
+
31
+ def shift_count(node)
32
+ count = count_of(node)
33
+ return if count.nil? || count.is_a?(Prism::IntegerNode)
34
+
35
+ add_mutation(
36
+ offset: count.location.start_offset,
37
+ length: count.location.length,
38
+ replacement: lowered(node, count),
39
+ node: node
40
+ )
41
+ end
42
+
43
+ def count_of(node)
44
+ if RECEIVER_COUNT_METHODS.include?(node.name)
45
+ node.receiver if node.arguments.nil?
46
+ elsif ARGUMENT_COUNT_METHODS.include?(node.name)
47
+ single_argument(node)
48
+ end
49
+ end
50
+
51
+ def single_argument(node)
52
+ return nil if node.arguments.nil?
53
+
54
+ arguments = node.arguments.arguments
55
+ return nil unless arguments.length == 1
56
+ return nil if NON_VALUE_ARGUMENT_TYPES.include?(arguments.first.class)
57
+
58
+ arguments.first
59
+ end
60
+
61
+ # A receiver is followed by `.method`, so the subtraction is grouped; an
62
+ # argument stands on its own and only needs grouping when it is not a
63
+ # primary expression.
64
+ def lowered(node, count)
65
+ subtraction = "#{receiver_source(count)} - 1"
66
+ count.equal?(node.receiver) ? "(#{subtraction})" : subtraction
67
+ end
68
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Retarget an operator write on an attribute at the instance variable it
6
+ # goes through: `@account.balance += amount` becomes `@account += amount`.
7
+ #
8
+ # The value is still computed and still assigned, only to the wrong place:
9
+ # the object's attribute is left as it was and the variable is replaced. A
10
+ # survivor means the tests exercise the line without ever looking at what it
11
+ # was meant to change. Where the object does not answer the operator the
12
+ # mutant raises NoMethodError, which any test reaching the line kills.
13
+ #
14
+ # Only a receiver that is an instance variable qualifies. `self` cannot be
15
+ # assigned to, and behind a longer receiver (`@user.account.balance += x`)
16
+ # sits another attribute, not a variable.
17
+ class Evilution::Mutator::Operator::OperatorWriteReceiverPromotion < Evilution::Mutator::Base
18
+ def visit_call_operator_write_node(node)
19
+ promote_receiver(node) if node.receiver.is_a?(Prism::InstanceVariableReadNode)
20
+ super
21
+ end
22
+
23
+ private
24
+
25
+ # Replaces `@account.balance`, up to the end of the attribute's name, with
26
+ # `@account`. What is left is an operator write on a variable, which always
27
+ # parses.
28
+ def promote_receiver(node)
29
+ offset = node.start_offset
30
+
31
+ add_mutation(
32
+ offset: offset,
33
+ length: node.message_loc.end_offset - offset,
34
+ replacement: source_of(node.receiver),
35
+ node: node
36
+ )
37
+ end
38
+ end
@@ -12,21 +12,32 @@ require_relative "../operator"
12
12
  # that pass the argument are unaffected, which is what separates this from a
13
13
  # mutation that breaks every call site.
14
14
  #
15
- # Ruby allows a required parameter to follow an optional one, so each optional
16
- # is mutated on its own and the rest of the signature stays as written.
15
+ # Optional parameters have to sit side by side, so only the ends of a run of
16
+ # them are mutated; a required parameter in the middle would split it in two.
17
+ # The first can always join the required parameters before it. The last can
18
+ # join those after it, unless a rest parameter follows: a required parameter
19
+ # cannot stand between an optional one and `*rest`. The rest of the signature
20
+ # stays as written.
17
21
  # KeywordArgument owns optional keyword parameters. Blocks are left alone: a
18
22
  # block ignores arity, so a missing argument arrives as nil rather than raising,
19
23
  # and making its parameter required would change nothing.
20
24
  class Evilution::Mutator::Operator::OptionalParameterToRequired < Evilution::Mutator::Base
21
25
  def visit_def_node(node)
22
26
  parameters = node.parameters
23
- parameters.optionals.each { |optional| require_parameter(optional) } if parameters
27
+ mutable_optionals(parameters).each { |optional| require_parameter(optional) } if parameters
24
28
 
25
29
  super
26
30
  end
27
31
 
28
32
  private
29
33
 
34
+ def mutable_optionals(parameters)
35
+ optionals = parameters.optionals
36
+ return optionals.first(1) if parameters.rest
37
+
38
+ [optionals.first, optionals.last].compact.uniq
39
+ end
40
+
30
41
  def require_parameter(node)
31
42
  location = node.location
32
43
  name_loc = node.name_loc
@@ -83,14 +83,25 @@ class Evilution::Mutator::Operator::PatternMatchingArray < Evilution::Mutator::B
83
83
  end
84
84
 
85
85
  def replace_pattern(node, parts)
86
+ constant = node.constant ? source_for(node.constant) : ""
87
+ opening, closing = delimiters(node)
88
+
86
89
  add_mutation(
87
90
  offset: node.location.start_offset,
88
91
  length: node.location.length,
89
- replacement: "[#{parts.join(", ")}]",
92
+ replacement: "#{constant}#{opening}#{parts.join(", ")}#{closing}",
90
93
  node: node
91
94
  )
92
95
  end
93
96
 
97
+ # A pattern written without brackets (`in a, b`) has no delimiters of its
98
+ # own, so the mutant is given square ones.
99
+ def delimiters(node)
100
+ return ["[", "]"] if node.opening_loc.nil?
101
+
102
+ [node.opening_loc.slice, node.closing_loc.slice]
103
+ end
104
+
94
105
  def source_for(node)
95
106
  @file_source.byteslice(node.location.start_offset, node.location.length)
96
107
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Widen a pattern so it accepts input the original rejects:
6
+ #
7
+ # in [a, b] -> in [a, b, *] longer arrays
8
+ # in { age: Integer } -> in { age: _ } any value under the key
9
+ # in { name: String, age: Integer }
10
+ # -> in { name: String } hashes without the key
11
+ # in { kind: :a, **nil } -> in { kind: :a } hashes with other keys
12
+ #
13
+ # A survivor means no example feeds the pattern one of the shapes it is written
14
+ # to turn away.
15
+ #
16
+ # Elements of array and find patterns are widened by PatternMatchingArray. Here
17
+ # the pattern itself is opened up, and every edit keeps the variables the
18
+ # pattern binds, so the branch body still runs as written.
19
+ class Evilution::Mutator::Operator::PatternWildcardWidening < Evilution::Mutator::Base
20
+ WILDCARD = "_"
21
+ UNWIDENABLE_VALUE_TYPES = [Prism::LocalVariableTargetNode, Prism::ImplicitNode].freeze
22
+
23
+ def visit_array_pattern_node(node)
24
+ append_rest(node)
25
+ super
26
+ end
27
+
28
+ def visit_hash_pattern_node(node)
29
+ widen_pairs(node)
30
+ drop_closing_rest(node)
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ # A pattern with a rest is open already, at whichever end the rest sits, and
37
+ # an empty one has no element to append after.
38
+ def append_rest(node)
39
+ return unless node.rest.nil?
40
+ return if node.requireds.empty?
41
+
42
+ add_mutation(
43
+ offset: node.requireds.last.location.end_offset,
44
+ length: 0,
45
+ replacement: ", *",
46
+ node: node
47
+ )
48
+ end
49
+
50
+ def widen_pairs(node)
51
+ node.elements.each_with_index do |pair, index|
52
+ next if binds_variable?(pair.value)
53
+
54
+ wildcard_value(node, pair)
55
+ # `in {}` matches the empty hash only, so dropping the last key would
56
+ # narrow the pattern rather than widen it.
57
+ drop_pair(node, index) if node.elements.length > 1
58
+ end
59
+ end
60
+
61
+ # A bare name reaching here is an underscore one, which already accepts any
62
+ # value; rewriting it to `_` would change nothing. A shorthand key
63
+ # (`_ignored:`) has no value pattern of its own: its implicit value spans the
64
+ # key, so writing a wildcard there would rename the key instead.
65
+ def wildcard_value(node, pair)
66
+ return if UNWIDENABLE_VALUE_TYPES.include?(pair.value.class)
67
+
68
+ location = pair.value.location
69
+
70
+ add_mutation(
71
+ offset: location.start_offset,
72
+ length: location.length,
73
+ replacement: WILDCARD,
74
+ node: node
75
+ )
76
+ end
77
+
78
+ def drop_pair(node, index)
79
+ remaining = node.elements.dup
80
+ remaining.delete_at(index)
81
+ replace_members(node, remaining + [node.rest].compact)
82
+ end
83
+
84
+ # `**nil` forbids keys the pattern does not name. On its own it leaves a
85
+ # pattern that matches the empty hash only, with or without it.
86
+ def drop_closing_rest(node)
87
+ return unless node.rest.is_a?(Prism::NoKeywordsParameterNode)
88
+ return if node.elements.empty?
89
+
90
+ replace_members(node, node.elements)
91
+ end
92
+
93
+ # Rewrites what sits between the delimiters, so braces, parentheses and a
94
+ # constant in front of them stay as written.
95
+ def replace_members(node, members)
96
+ all = node.elements + [node.rest].compact
97
+ start_offset = all.first.location.start_offset
98
+ end_offset = all.last.location.end_offset
99
+
100
+ add_mutation(
101
+ offset: start_offset,
102
+ length: end_offset - start_offset,
103
+ replacement: members.map(&:slice).join(", "),
104
+ node: node
105
+ )
106
+ end
107
+
108
+ # Whether the value pattern captures into a variable the branch body could
109
+ # read. Taking such a pair away, or widening it to a wildcard, would break
110
+ # the body on the missing name whatever the tests assert. An underscore name
111
+ # is a wildcard by convention and is not counted.
112
+ def binds_variable?(node)
113
+ return !node.name.start_with?(WILDCARD) if node.is_a?(Prism::LocalVariableTargetNode)
114
+
115
+ node.compact_child_nodes.any? { |child| binds_variable?(child) }
116
+ end
117
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Drop the pin of a pattern variable: `in ^expected` becomes `in expected`.
6
+ #
7
+ # A pinned variable compares the matched value against an existing binding;
8
+ # without the pin the same name captures whatever is there, so the pattern
9
+ # matches every value. A survivor means no example feeds the pattern a value
10
+ # that differs from the pinned one.
11
+ class Evilution::Mutator::Operator::PinOperatorRemoval < Evilution::Mutator::Base
12
+ def initialize(**options)
13
+ super
14
+ @alternation_depth = 0
15
+ end
16
+
17
+ # An alternative pattern (`^a | ^b`) may not capture, at any depth inside it,
18
+ # so no pin under one has an unpinned form. Tracked here rather than left to
19
+ # the parse check: older Prism releases accept the capture that Ruby rejects.
20
+ def visit_alternation_pattern_node(node)
21
+ @alternation_depth += 1
22
+ super
23
+ ensure
24
+ @alternation_depth -= 1
25
+ end
26
+
27
+ def visit_pinned_variable_node(node)
28
+ remove_pin(node) if @alternation_depth.zero?
29
+ super
30
+ end
31
+
32
+ private
33
+
34
+ # Other pins without an unpinned form are caught by the parse check. Only a
35
+ # local name can stand as a capturing pattern, so `^@expected` and
36
+ # `^$expected` have none, and neither does a numbered parameter (`^_1`).
37
+ # Those rewrites carry no signal, so they are dropped rather than reported
38
+ # as unparseable.
39
+ def remove_pin(node)
40
+ add_mutation(
41
+ offset: node.location.start_offset,
42
+ length: node.location.length,
43
+ replacement: node.variable.slice,
44
+ node: node,
45
+ skip_unparseable: true
46
+ )
47
+ end
48
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::RangeReplacement < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  def visit_range_node(node)
7
9
  replacement = node.operator == ".." ? "..." : ".."
8
10