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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap the operands of a spaceship comparison: `a <=> b` becomes `b <=> a`.
6
+ #
7
+ # The result changes sign, so a sort block sorts the other way and a custom
8
+ # `<=>` orders the other way round. A survivor means no example asserts the
9
+ # direction of the ordering.
10
+ #
11
+ # Two-argument ordering methods (`between?`, `clamp`) are reversed by
12
+ # ArgumentOrderPermutation. Identical operands are not swapped, since that
13
+ # would reproduce the original comparison, and neither is a safe-navigation
14
+ # call, where moving the other operand into the receiver's place changes what
15
+ # may be nil.
16
+ class Evilution::Mutator::Operator::ComparisonOperandSwap < Evilution::Mutator::Base
17
+ def visit_call_node(node)
18
+ swap_operands(node) if spaceship?(node)
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ # Ruby has no receiverless spaceship, but the explicit form can be written
25
+ # with no argument or several (`a.<=>`, `a.<=>(b, c)`); those have no pair
26
+ # of operands to swap.
27
+ def spaceship?(node)
28
+ return false unless node.name == :<=> && !node.safe_navigation?
29
+ return false if node.arguments.nil?
30
+
31
+ node.arguments.arguments.length == 1
32
+ end
33
+
34
+ # Whatever sits between the operands — the operator and its spacing, or
35
+ # `.<=>(` in the explicit form — stays in place.
36
+ def swap_operands(node)
37
+ left = node.receiver
38
+ right = node.arguments.arguments.first
39
+ return if left.slice == right.slice
40
+
41
+ start_offset = left.location.start_offset
42
+
43
+ add_mutation(
44
+ offset: start_offset,
45
+ length: right.location.end_offset - start_offset,
46
+ replacement: "#{right.slice}#{source_between(left, right)}#{left.slice}",
47
+ node: node
48
+ )
49
+ end
50
+
51
+ def source_between(left, right)
52
+ byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a complex literal (`5i`) with `0i`, `1i`, its two neighbours along
6
+ # the imaginary axis (`6i`, `4i`) and `nil`.
7
+ #
8
+ # The literal is replaced as a whole: IntegerLiteral and FloatLiteral leave
9
+ # its numeric part alone.
10
+ class Evilution::Mutator::Operator::ComplexLiteral < Evilution::Mutator::Base
11
+ extend Evilution::Mutator::ConstantValueSubjects
12
+
13
+ def visit_imaginary_node(node)
14
+ replacements_for(node.numeric).each { |part| add_mutation_with_replacement(node, "#{part}i") }
15
+ add_mutation_with_replacement(node, "nil")
16
+
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ # Equal values are emitted once, the integer first: `1i` over `1.0i`.
23
+ def replacements_for(numeric)
24
+ part = numeric.value
25
+ [0, 1, *neighbours_of(numeric)].reject { |candidate| candidate == part }.uniq(&:to_r)
26
+ end
27
+
28
+ # A rational part (`3ri`) gets none: its neighbours have no literal of
29
+ # their own in general.
30
+ def neighbours_of(numeric)
31
+ return [] if numeric.is_a?(Prism::RationalNode)
32
+
33
+ [numeric.value + 1, numeric.value - 1]
34
+ end
35
+
36
+ def add_mutation_with_replacement(node, replacement)
37
+ add_mutation(offset: node.location.start_offset, length: node.location.length, replacement:, node:)
38
+ end
39
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::CompoundAssignment < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  REPLACEMENTS = {
7
9
  :+ => %i[- *],
8
10
  :- => [:+],
@@ -37,6 +39,51 @@ class Evilution::Mutator::Operator::CompoundAssignment < Evilution::Mutator::Bas
37
39
  super
38
40
  end
39
41
 
42
+ # `obj.count += 1`: the target is read through `count` and written
43
+ # through `count=`.
44
+ def visit_call_operator_write_node(node)
45
+ mutate_operator_write(node)
46
+ super
47
+ end
48
+
49
+ # `counts[key] += 1`: read through `[]` and written through `[]=`.
50
+ def visit_index_operator_write_node(node)
51
+ mutate_operator_write(node)
52
+ super
53
+ end
54
+
55
+ # `TOTAL += 1` is written outside any method and is a constant-write
56
+ # subject of its own, like the assignment `TOTAL = 1` before it.
57
+ def visit_constant_operator_write_node(node)
58
+ mutate_operator_write(node)
59
+ super
60
+ end
61
+
62
+ def visit_constant_path_operator_write_node(node)
63
+ mutate_operator_write(node)
64
+ super
65
+ end
66
+
67
+ def visit_call_and_write_node(node)
68
+ mutate_logical_write(node, "||=")
69
+ super
70
+ end
71
+
72
+ def visit_call_or_write_node(node)
73
+ mutate_logical_write(node, "&&=")
74
+ super
75
+ end
76
+
77
+ def visit_index_and_write_node(node)
78
+ mutate_logical_write(node, "||=")
79
+ super
80
+ end
81
+
82
+ def visit_index_or_write_node(node)
83
+ mutate_logical_write(node, "&&=")
84
+ super
85
+ end
86
+
40
87
  def visit_local_variable_and_write_node(node)
41
88
  mutate_logical_write(node, "||=")
42
89
  super
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Strip the namespace off a constant path: `Config::LIMIT` becomes `LIMIT`.
6
+ # A longer path is stripped at each level, so `App::Config::LIMIT` gives
7
+ # both `LIMIT` and `Config::LIMIT`.
8
+ #
9
+ # The bare name is looked up from where the code stands instead of inside
10
+ # the namespace. It either finds another constant or raises NameError; a
11
+ # survivor means the same constant is reachable without the qualifier, or
12
+ # that nothing depends on which one is found. A namespace given by an
13
+ # expression (`self.class::LIMIT`) is stripped too — there the survivor says
14
+ # no test overrides the constant in a subclass.
15
+ #
16
+ # A top-level path (`::LIMIT`) has no namespace to strip. The target of an
17
+ # operator write (`Config::LIMIT ||= x`) is left alone: bare, `LIMIT ||= x`
18
+ # is a dynamic constant assignment inside a method. Prism lets it through,
19
+ # where parse.y rejects it with a SyntaxError, so whether the mutant exists
20
+ # would depend on the parser the interpreter compiles with.
21
+ class Evilution::Mutator::Operator::ConstantNamespaceStrip < Evilution::Mutator::Base
22
+ def call(subject, **)
23
+ @write_targets = Set.new
24
+ super
25
+ end
26
+
27
+ def visit_constant_path_or_write_node(node)
28
+ @write_targets.add(node.target)
29
+ super
30
+ end
31
+
32
+ def visit_constant_path_and_write_node(node)
33
+ @write_targets.add(node.target)
34
+ super
35
+ end
36
+
37
+ def visit_constant_path_operator_write_node(node)
38
+ @write_targets.add(node.target)
39
+ super
40
+ end
41
+
42
+ def visit_constant_path_node(node)
43
+ replace_span(node: node, target: node, replacement: name_source(node)) if strippable?(node)
44
+ super
45
+ end
46
+
47
+ private
48
+
49
+ def strippable?(node)
50
+ node.parent && !@write_targets.include?(node)
51
+ end
52
+
53
+ def name_source(node)
54
+ location = node.name_loc
55
+ byteslice_source(location.start_offset, location.length)
56
+ end
57
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a constant reference with `nil`: `MAX` becomes `nil`, and a path
6
+ # such as `Config::MAX` goes as a whole.
7
+ #
8
+ # A survivor means the constant's value never reaches an assertion — the
9
+ # tests would pass whatever it held.
10
+ #
11
+ # Four positions are skipped as noise. A constant used as the receiver of a
12
+ # call turns into `nil.new`, a NoMethodError any test that reaches it kills;
13
+ # so does the receiver of a write (`Counter.total += 1`, `CACHE[key] ||= v`).
14
+ # The namespace of a path would leave `nil::MAX`, a TypeError; the path is
15
+ # nil-ified instead. A constant in void statement position (every statement
16
+ # of a body except the last) has no value to replace; deleting it is
17
+ # statement_deletion's job. And the exception classes of a `rescue` clause
18
+ # belong to the rescue operators — `rescue nil` only fails once something is
19
+ # raised, and then with a TypeError of its own.
20
+ class Evilution::Mutator::Operator::ConstantReadToNil < Evilution::Mutator::Base
21
+ include Evilution::Mutator::ReceiverPositions
22
+
23
+ def call(subject, **)
24
+ @skipped_nodes = Set.new
25
+ super
26
+ end
27
+
28
+ def visit_statements_node(node)
29
+ @skipped_nodes.merge(node.body[...-1])
30
+ super
31
+ end
32
+
33
+ def visit_rescue_node(node)
34
+ @skipped_nodes.merge(node.exceptions)
35
+ super
36
+ end
37
+
38
+ def visit_constant_path_node(node)
39
+ @skipped_nodes.add(node.parent)
40
+ nilify(node)
41
+ super
42
+ end
43
+
44
+ def visit_constant_read_node(node)
45
+ nilify(node)
46
+ super
47
+ end
48
+
49
+ private
50
+
51
+ def receiver_found(node)
52
+ @skipped_nodes.add(node)
53
+ end
54
+
55
+ def nilify(node)
56
+ mutate_to_nil(node) unless @skipped_nodes.include?(node)
57
+ end
58
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Assign `nil` to a constant instead of its value: `LIMIT = 10` becomes
6
+ # `LIMIT = nil`.
7
+ #
8
+ # A survivor means nothing the tests run depends on what the constant holds.
9
+ # The literal operators go on to change the value piece by piece; this one
10
+ # takes it away whole, whatever it is — a literal, a call, a lambda.
11
+ #
12
+ # It works on constant-write subjects, each being one assignment, and does
13
+ # not look inside the value: a constant assigned there (in the block of a
14
+ # `Class.new`) is a subject of its own. A constant already assigned `nil`
15
+ # has nothing to lose. An operator write (`LIMIT += 1`) is left to
16
+ # compound_assignment, which replaces the whole of it with `nil`.
17
+ #
18
+ # Code that read the constant while the file first loaded keeps the old
19
+ # value (see Subject), so only what reads it when called sees the mutant.
20
+ class Evilution::Mutator::Operator::ConstantWriteToNil < Evilution::Mutator::Base
21
+ def self.subject_kinds
22
+ %i[constant_write]
23
+ end
24
+
25
+ def visit_constant_write_node(node)
26
+ mutate_to_nil(node, target: node.value)
27
+ end
28
+
29
+ def visit_constant_path_write_node(node)
30
+ mutate_to_nil(node, target: node.value)
31
+ end
32
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+ require_relative "../../ast/value_object_definition"
7
+
8
+ # Mutate the member list of a value-object definition: `Data.define(:a, :b)`
9
+ # and `Struct.new(:a, :b)` lose one member at a time, and have each adjacent
10
+ # pair of members swapped.
11
+ #
12
+ # A surviving drop means no example reads or sets that member. A surviving swap
13
+ # means the type is built positionally somewhere the order is never asserted,
14
+ # so two members could trade values unnoticed.
15
+ #
16
+ # Definitions outside any method — assigned to a constant, or the superclass
17
+ # of a class — are constant subjects of their own, and this operator mutates
18
+ # them there. A constant subject is the definition alone: definitions nested
19
+ # in its block are subjects of their own too.
20
+ class Evilution::Mutator::Operator::DataStructMember < Evilution::Mutator::Base
21
+ # Arguments whose position and meaning are known: members are symbols, a
22
+ # string is the class name `Struct.new("Name", ...)` takes first, and the
23
+ # keyword hash carries `keyword_init:`. Anything else (a splat, a variable)
24
+ # hides the member list, so the definition is left alone.
25
+ KNOWN_ARGUMENT_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::KeywordHashNode].freeze
26
+
27
+ def self.subject_kinds
28
+ %i[method constant]
29
+ end
30
+
31
+ # A constant subject's node is the definition itself, so the visit stops
32
+ # there rather than reaching definitions nested in its block.
33
+ def visit_call_node(node)
34
+ mutate_members(node) if Evilution::AST::ValueObjectDefinition.match?(node)
35
+ super unless @subject.kind == :constant
36
+ end
37
+
38
+ private
39
+
40
+ def mutate_members(node)
41
+ arguments = argument_nodes(node)
42
+ members = member_indexes(arguments)
43
+ # A lone member has no neighbour to swap with, and dropping it would leave
44
+ # `Struct.new()`, which raises on Rubies that require at least one member.
45
+ return if members.length < 2
46
+
47
+ slices = arguments.map(&:slice)
48
+ members.each { |index| emit_member_drop(node, slices, index) }
49
+ members.each_cons(2) { |left, right| emit_member_swap(node, slices, left, right) }
50
+ end
51
+
52
+ def member_indexes(arguments)
53
+ return [] unless arguments.all? { |argument| KNOWN_ARGUMENT_TYPES.include?(argument.class) }
54
+
55
+ arguments.each_index.select { |index| arguments[index].is_a?(Prism::SymbolNode) }
56
+ end
57
+
58
+ def emit_member_drop(node, slices, index)
59
+ remaining = slices.dup
60
+ remaining.delete_at(index)
61
+ replace_arguments(node, remaining)
62
+ end
63
+
64
+ def emit_member_swap(node, slices, left, right)
65
+ return if slices[left] == slices[right]
66
+
67
+ swapped = slices.dup
68
+ swapped[left], swapped[right] = swapped[right], swapped[left]
69
+ replace_arguments(node, swapped)
70
+ end
71
+
72
+ def replace_arguments(node, slices)
73
+ location = node.arguments.location
74
+
75
+ add_mutation(
76
+ offset: location.start_offset,
77
+ length: location.length,
78
+ replacement: slices.join(", "),
79
+ node: node
80
+ )
81
+ end
82
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swallow the error of a statement that raises by convention:
6
+ # `record.save!` becomes `record.save! rescue nil`.
7
+ #
8
+ # The mutant behaves like the original until the statement fails; then it
9
+ # carries on with nil instead of raising. A survivor means no example makes
10
+ # that statement fail and checks the error comes out. It is the inverse of
11
+ # RescueRemoval: handling is added where there is none.
12
+ #
13
+ # Only calls whose failure is part of their contract are reached: bang
14
+ # methods, `fetch`, and the conversion functions `Integer`, `Float` and
15
+ # `Rational`. Wrapping every statement would mostly report that no test makes
16
+ # a log line raise. Statements already covered by a rescue are left alone, as
17
+ # is `raise`, whose swallowed form behaves as deleting the statement.
18
+ class Evilution::Mutator::Operator::ExceptionSwallow < Evilution::Mutator::Base
19
+ CONVERSION_FUNCTIONS = %i[Integer Float Rational].freeze
20
+
21
+ # A bang method name, as opposed to the `!` and `!=` operators.
22
+ BANG_METHOD = /\w!\z/
23
+
24
+ # Bangs that do not raise: Ruby's in-place methods mark a change to the
25
+ # receiver, and exit! ends the process without raising.
26
+ NON_RAISING_BANGS = %i[
27
+ uniq! sort! sort_by! select! filter! reject! map! collect! compact! flatten!
28
+ shuffle! reverse! rotate! slice! merge! transform_keys! transform_values!
29
+ gsub! sub! strip! lstrip! rstrip! chomp! chop! squeeze! tr! tr_s! delete!
30
+ downcase! upcase! capitalize! swapcase! unicode_normalize! scrub! encode!
31
+ exit!
32
+ ].freeze
33
+
34
+ # Assignments whose value is the statement's call.
35
+ WRITE_TYPES = [
36
+ Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode,
37
+ Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode
38
+ ].freeze
39
+
40
+ def visit_statements_node(node)
41
+ node.body.each { |statement| swallow(statement) }
42
+ super
43
+ end
44
+
45
+ # Everything under a `begin` (or a `def` or block body) with a rescue clause
46
+ # is already handled there, so the visitor does not descend into it.
47
+ def visit_begin_node(node)
48
+ super if node.rescue_clause.nil?
49
+ end
50
+
51
+ private
52
+
53
+ def swallow(statement)
54
+ call = statement_call(statement)
55
+ return unless call && raising?(call)
56
+
57
+ add_mutation(
58
+ offset: statement.location.end_offset,
59
+ length: 0,
60
+ replacement: " rescue nil",
61
+ node: call
62
+ )
63
+ end
64
+
65
+ def statement_call(statement)
66
+ statement = statement.value if WRITE_TYPES.include?(statement.class)
67
+ statement if statement.is_a?(Prism::CallNode)
68
+ end
69
+
70
+ def raising?(call)
71
+ name = call.name
72
+ return !NON_RAISING_BANGS.include?(name) if name.to_s.match?(BANG_METHOD)
73
+ return raising_fetch?(call) if name == :fetch
74
+
75
+ call.receiver.nil? && CONVERSION_FUNCTIONS.include?(name)
76
+ end
77
+
78
+ # With a default value or a block, fetch returns that instead of raising.
79
+ def raising_fetch?(call)
80
+ return false if call.receiver.nil? || call.block
81
+
82
+ call.arguments.nil? || call.arguments.arguments.length == 1
83
+ end
84
+ end
@@ -5,8 +5,8 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::ExplicitSuperMutation < Evilution::Mutator::Base
6
6
  def visit_super_node(node)
7
7
  replace_with_zsuper(node)
8
- args = node.arguments&.arguments
9
- mutate_arguments(node, args) if args && !args.empty?
8
+ args = argument_nodes(node)
9
+ mutate_arguments(node, args) unless args.empty?
10
10
 
11
11
  super
12
12
  end
@@ -3,27 +3,62 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::FloatLiteral < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
8
+ # NaN fails every comparison and equality; the infinities break arithmetic
9
+ # on bounds. None of them can be reached from `0.0`, `1.0` or `nil`.
10
+ SPECIAL_VALUES = ["Float::NAN", "Float::INFINITY", "-Float::INFINITY"].freeze
11
+
12
+ # A guard wraps the pattern of its branch: `in Float if x > 1.0`.
13
+ GUARDS = [Prism::IfNode, Prism::UnlessNode].freeze
14
+
6
15
  def visit_float_node(node)
7
- replacement = case node.value
8
- when 0.0 then "1.0"
9
- when 1.0 then "0.0"
10
- else "0.0"
11
- end
12
-
13
- add_mutation(
14
- offset: node.location.start_offset,
15
- length: node.location.length,
16
- replacement: replacement,
17
- node: node
18
- )
19
-
20
- add_mutation(
21
- offset: node.location.start_offset,
22
- length: node.location.length,
23
- replacement: "nil",
24
- node: node
25
- )
16
+ add_mutation_with_replacement(node, node.value == 0.0 ? "1.0" : "0.0")
17
+ SPECIAL_VALUES.each { |value| add_mutation_with_replacement(node, value) } unless pattern_literals.include?(node)
18
+ add_mutation_with_replacement(node, "nil")
19
+
20
+ super
21
+ end
26
22
 
23
+ # A pattern takes literals, not expressions: `in -Float::INFINITY` and
24
+ # `in Float::NAN..2.0` do not parse. The floats of a pattern keep the
25
+ # literal replacements only. A guard and a pinned expression (`^(x + 1.0)`)
26
+ # are ordinary code.
27
+ def visit_in_node(node)
28
+ pattern = node.pattern
29
+ pattern = pattern.statements if GUARDS.include?(pattern.class)
30
+ mark_pattern_literals(pattern)
27
31
  super
28
32
  end
33
+
34
+ def visit_match_predicate_node(node)
35
+ mark_pattern_literals(node.pattern)
36
+ super
37
+ end
38
+
39
+ def visit_match_required_node(node)
40
+ mark_pattern_literals(node.pattern)
41
+ super
42
+ end
43
+
44
+ # A complex literal (`5i`) is replaced as a whole by ComplexLiteral. Its
45
+ # numeric part is not a literal of its own: `nili` is not a value.
46
+ def visit_imaginary_node(_node); end
47
+
48
+ private
49
+
50
+ def pattern_literals
51
+ @pattern_literals ||= Set.new.compare_by_identity
52
+ end
53
+
54
+ def mark_pattern_literals(node)
55
+ return if node.is_a?(Prism::PinnedExpressionNode)
56
+
57
+ pattern_literals.add(node) if node.is_a?(Prism::FloatNode)
58
+ node.compact_child_nodes.each { |child| mark_pattern_literals(child) }
59
+ end
60
+
61
+ def add_mutation_with_replacement(node, replacement)
62
+ add_mutation(offset: node.location.start_offset, length: node.location.length, replacement:, node:)
63
+ end
29
64
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Loosen the conversion specifiers of a literal format string, one at a time:
6
+ #
7
+ # format("%05d", n) -> format("%d", n) flags and width dropped
8
+ # sprintf("%.2f", x) -> sprintf("%f", x) precision dropped
9
+ # -> sprintf("%s", x) rendered as a plain string
10
+ #
11
+ # A survivor means the output is produced but its shape — padding, decimals,
12
+ # number base — is never asserted.
13
+ #
14
+ # Reaches the format string of receiverless `format`, `sprintf` and `printf`
15
+ # and the string receiver of `"..." % args`, when it is a plain literal. Integer
16
+ # conversions (`%d`, `%i`, `%u`) are not turned into `%s`: for an integer
17
+ # argument both print the same digits.
18
+ class Evilution::Mutator::Operator::FormatSpecifierSwap < Evilution::Mutator::Base
19
+ FORMAT_METHODS = %i[format sprintf printf].freeze
20
+
21
+ # `%%` and `%{name}` are matched so they are not mistaken for the start of
22
+ # a specifier; neither is mutated.
23
+ SPECIFIER = /
24
+ %(?:
25
+ %
26
+ | \{\w+\}
27
+ | (?<name><\w+>)?(?<flags>[-+\ 0\#]*)(?<width>\d+|\*)?(?:\.(?<precision>\d+|\*))?(?<type>[a-zA-Z])
28
+ )
29
+ /x
30
+
31
+ # Conversions whose output differs from `%s` for the values they take.
32
+ STRINGIFIABLE_TYPES = %w[f e E g G a A x X o b B].freeze
33
+
34
+ def visit_call_node(node)
35
+ format_string = format_string_of(node)
36
+ mutate_specifiers(node, format_string) if format_string
37
+ super
38
+ end
39
+
40
+ private
41
+
42
+ def format_string_of(node)
43
+ candidate =
44
+ if node.receiver.nil? && FORMAT_METHODS.include?(node.name)
45
+ first_argument(node)
46
+ elsif node.name == :%
47
+ node.receiver
48
+ end
49
+
50
+ candidate if plain_literal?(candidate)
51
+ end
52
+
53
+ def first_argument(node)
54
+ node.arguments.arguments.first if node.arguments
55
+ end
56
+
57
+ # A heredoc's content sits after the line that opens it, so edits inside
58
+ # it are left alone along with interpolated strings. Only `%w[]` elements
59
+ # lack an opening delimiter, and those never stand here on their own.
60
+ def plain_literal?(node)
61
+ node.is_a?(Prism::StringNode) && !node.opening_loc.slice.start_with?("<<")
62
+ end
63
+
64
+ def mutate_specifiers(node, format_string)
65
+ specifiers(format_string).each do |match, offset|
66
+ variants(match).each do |replacement|
67
+ add_mutation(offset: offset, length: match[0].bytesize, replacement: replacement, node: node)
68
+ end
69
+ end
70
+ end
71
+
72
+ # Each conversion specifier with its offset in the file. The scan runs over
73
+ # the source text between the quotes, so offsets line up with the file even
74
+ # when the string holds escapes.
75
+ def specifiers(format_string)
76
+ content = format_string.content_loc
77
+ text = byteslice_source(content.start_offset, content.length)
78
+
79
+ text.to_enum(:scan, SPECIFIER).filter_map do
80
+ match = Regexp.last_match
81
+ [match, content.start_offset + match.byteoffset(0).first] unless match[:type].nil?
82
+ end
83
+ end
84
+
85
+ # A `*` width or precision consumes an argument of its own, so dropping it
86
+ # would shift every argument after it.
87
+ def variants(match)
88
+ return [] if [match[:width], match[:precision]].include?("*")
89
+
90
+ name = match[:name]
91
+ replacements = []
92
+ replacements << "%#{name}#{match[:type]}" if modifiers?(match)
93
+ replacements << "%#{name}s" if STRINGIFIABLE_TYPES.include?(match[:type])
94
+ replacements
95
+ end
96
+
97
+ def modifiers?(match)
98
+ !match[:flags].empty? || !match[:width].nil? || !match[:precision].nil?
99
+ end
100
+ end