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,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Stop forwarding one part of the arguments a method passes through unnamed:
6
+ # `target.call(*, **, &)` becomes `target.call(**, &)`, and so on for each part.
7
+ #
8
+ # A survivor means that part never reaches an assertion — nothing checks that
9
+ # the positional arguments, the keywords or the block arrive at the other end.
10
+ #
11
+ # Two spellings are handled. Anonymous `*` and `**` are dropped from the
12
+ # argument list they are written in. A `...` cannot be split where it stands,
13
+ # because `*`, `**` and `&` may not be written under a `...` signature, so the
14
+ # signature is spelled out as `(*, **, &)` along with every use, and one use
15
+ # loses one part: `def f(...) = g(...)` becomes `def f(*, **, &) = g(**, &)`.
16
+ #
17
+ # Named splats are left to SplatOperator and KeywordArgument, and an anonymous
18
+ # `&` written on its own to BlockPassRemoval.
19
+ class Evilution::Mutator::Operator::ForwardedArgumentDrop < Evilution::Mutator::Base
20
+ PARTS = ["*", "**", "&"].freeze
21
+ ALL_PARTS = PARTS.join(", ").freeze
22
+
23
+ def visit_def_node(node)
24
+ drop_forwarded_parts(node)
25
+ super
26
+ end
27
+
28
+ def visit_call_node(node)
29
+ drop_anonymous_arguments(node)
30
+ super
31
+ end
32
+
33
+ def visit_super_node(node)
34
+ drop_anonymous_arguments(node)
35
+ super
36
+ end
37
+
38
+ def visit_yield_node(node)
39
+ drop_anonymous_arguments(node)
40
+ super
41
+ end
42
+
43
+ private
44
+
45
+ def drop_anonymous_arguments(node)
46
+ return if node.arguments.nil?
47
+
48
+ items = argument_items(node.arguments)
49
+ # Dropping the only argument leaves an empty list, which is the mutant
50
+ # ArgumentListRemoval already emits.
51
+ return if items.length < 2
52
+
53
+ items.each_index do |index|
54
+ emit_argument_drop(node, items, index) if anonymous?(items[index])
55
+ end
56
+ end
57
+
58
+ # Keywords sit together in one KeywordHashNode, `**` among them; they are
59
+ # listed individually so each can be dropped on its own.
60
+ def argument_items(arguments)
61
+ arguments.arguments.flat_map do |argument|
62
+ argument.is_a?(Prism::KeywordHashNode) ? argument.elements : [argument]
63
+ end
64
+ end
65
+
66
+ def anonymous?(item)
67
+ case item
68
+ when Prism::SplatNode then item.expression.nil?
69
+ when Prism::AssocSplatNode then item.value.nil?
70
+ else false
71
+ end
72
+ end
73
+
74
+ def emit_argument_drop(node, items, index)
75
+ remaining = items.map(&:slice)
76
+ remaining.delete_at(index)
77
+ location = node.arguments.location
78
+
79
+ add_mutation(
80
+ offset: location.start_offset,
81
+ length: location.length,
82
+ replacement: remaining.join(", "),
83
+ node: node
84
+ )
85
+ end
86
+
87
+ def drop_forwarded_parts(node)
88
+ return if node.body.nil?
89
+
90
+ uses = forwarding_uses(node.body)
91
+ uses.each do |target|
92
+ PARTS.each { |part| emit_part_drop(node, uses, target, part) }
93
+ end
94
+ end
95
+
96
+ # Every `...` the method forwards. A method defined in the body is not
97
+ # searched: its `...` forwards that method's own arguments.
98
+ def forwarding_uses(node)
99
+ return [] if node.is_a?(Prism::DefNode)
100
+ return [node] if node.is_a?(Prism::ForwardingArgumentsNode)
101
+
102
+ node.compact_child_nodes.flat_map { |child| forwarding_uses(child) }
103
+ end
104
+
105
+ # A `...` can only be forwarded by the method that declares it, so a use
106
+ # found in the body means the signature ends in the matching parameter.
107
+ def emit_part_drop(node, uses, target, part)
108
+ edits = uses.map do |use|
109
+ [use.location, use.equal?(target) ? (PARTS - [part]).join(", ") : ALL_PARTS]
110
+ end
111
+ edits << [node.parameters.keyword_rest.location, ALL_PARTS]
112
+
113
+ emit_rewrite(node, edits.sort_by { |location, _text| location.start_offset })
114
+ end
115
+
116
+ # The signature and the uses are apart in the source, while a mutation
117
+ # replaces one contiguous range, so the range runs from the first edit to
118
+ # the last and is rewritten with every edit applied.
119
+ def emit_rewrite(node, edits)
120
+ start_offset = edits.first.first.start_offset
121
+ length = edits.last.first.end_offset - start_offset
122
+
123
+ add_mutation(
124
+ offset: start_offset,
125
+ length: length,
126
+ replacement: apply_edits(byteslice_source(start_offset, length), edits, start_offset),
127
+ node: node
128
+ )
129
+ end
130
+
131
+ # Applied last to first, so an edit never shifts the offsets of those still
132
+ # to come.
133
+ def apply_edits(text, edits, base_offset)
134
+ edits.reverse_each do |location, replacement|
135
+ text = text.byteslice(0, location.start_offset - base_offset) +
136
+ replacement +
137
+ text.byteslice((location.end_offset - base_offset)..)
138
+ end
139
+
140
+ text
141
+ end
142
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+ require_relative "../../ast/class_body"
7
+
8
+ # Drop the `freeze` of a constant defined in a class or module body:
9
+ # `LIST = %w[a b].freeze` becomes `LIST = %w[a b]`.
10
+ #
11
+ # A survivor means no example tries to change the shared value, so nothing
12
+ # shows that it has to stay as it is.
13
+ #
14
+ # A constant is defined in the class body, outside every method, where no
15
+ # subject reaches it. The mutation is attributed to the method anchoring the
16
+ # enclosing body (see Evilution::AST::ClassBody); a top-level constant has no
17
+ # body to be attributed to. A `freeze` inside a method is left to the generic
18
+ # call operators, which already drop it.
19
+ class Evilution::Mutator::Operator::FreezeRemoval < Evilution::Mutator::Base
20
+ CONSTANT_WRITES = [Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::ConstantOrWriteNode].freeze
21
+
22
+ # Values that are frozen whether or not `freeze` is called.
23
+ ALWAYS_FROZEN = [
24
+ Prism::SymbolNode, Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::ImaginaryNode,
25
+ Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::RangeNode, Prism::RegularExpressionNode
26
+ ].freeze
27
+
28
+ # The comments a file opens with, where the magic comment has to be.
29
+ LEADING_COMMENTS = /\A(?:[ \t]*(?:#.*)?\n)*/
30
+ FROZEN_STRING_LITERALS = /^[ \t]*#.*frozen[_-]string[_-]literal:[ \t]*true/i
31
+ private_constant :CONSTANT_WRITES, :ALWAYS_FROZEN, :LEADING_COMMENTS, :FROZEN_STRING_LITERALS
32
+
33
+ def call(subject, filter: nil)
34
+ @subject = subject
35
+ @file_source = File.read(subject.file_path)
36
+ @mutations = []
37
+ @filter = filter
38
+
39
+ constant_writes_for(subject).each { |write| freezes_in(write).each { |freeze_call| remove(freeze_call) } }
40
+ @mutations
41
+ end
42
+
43
+ private
44
+
45
+ def remove(freeze_call)
46
+ add_mutation(
47
+ offset: freeze_call.location.start_offset,
48
+ length: freeze_call.location.length,
49
+ replacement: freeze_call.receiver.slice,
50
+ node: freeze_call
51
+ )
52
+ end
53
+
54
+ def constant_writes_for(subject)
55
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
56
+
57
+ Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
58
+ body.declarations { |node| CONSTANT_WRITES.include?(node.class) }
59
+ end
60
+ end
61
+
62
+ # Every `freeze` call under the node, outermost first.
63
+ def freezes_in(node)
64
+ nested = node.compact_child_nodes.flat_map { |child| freezes_in(child) }
65
+ removable_freeze?(node) ? [node, *nested] : nested
66
+ end
67
+
68
+ def removable_freeze?(node)
69
+ return false unless node.is_a?(Prism::CallNode) && node.name == :freeze
70
+ return false if node.receiver.nil? || node.arguments
71
+
72
+ !frozen_anyway?(node.receiver)
73
+ end
74
+
75
+ # A group has the value of its last expression: `(1..2)`.
76
+ def frozen_anyway?(receiver)
77
+ receiver = receiver.body.body.last while receiver.is_a?(Prism::ParenthesesNode) && receiver.body
78
+ return true if ALWAYS_FROZEN.include?(receiver.class)
79
+
80
+ receiver.is_a?(Prism::StringNode) && frozen_string_literals?
81
+ end
82
+
83
+ def frozen_string_literals?
84
+ @file_source[LEADING_COMMENTS].match?(FROZEN_STRING_LITERALS)
85
+ end
86
+ end
@@ -3,6 +3,10 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::HashLiteral < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
8
+ RENAMED_KEY = "__evilution_mutated__"
9
+
6
10
  def visit_hash_node(node)
7
11
  if node.elements.any?
8
12
  add_mutation(
@@ -18,8 +22,55 @@ class Evilution::Mutator::Operator::HashLiteral < Evilution::Mutator::Base
18
22
  replacement: "nil",
19
23
  node: node
20
24
  )
25
+
26
+ mutate_delete_pairs(node.elements)
27
+ mutate_rename_keys(node.elements)
21
28
  end
22
29
 
23
30
  super
24
31
  end
32
+
33
+ private
34
+
35
+ # Delete each key/value pair in turn: a survivor means no example checks
36
+ # that key. A single element is left to the `{}` replacement. A `**splat`
37
+ # is not a pair and stays. So does a pair holding a heredoc: its body sits
38
+ # after the closing brace, so one cut cannot take both, and the cut that
39
+ # does not parse is dropped.
40
+ def mutate_delete_pairs(elements)
41
+ return if elements.one?
42
+
43
+ elements.each_with_index do |element, index|
44
+ delete_element(elements, index) if element.is_a?(Prism::AssocNode)
45
+ end
46
+ end
47
+
48
+ # Rename each label key in turn (`a: 1`, `"a b": 1`): a survivor means no
49
+ # example reads the value by that name. A key written with a rocket
50
+ # (`:a => 1`, `"a" => 1`) is a literal of its own, and SymbolLiteral and
51
+ # StringLiteral already replace it.
52
+ def mutate_rename_keys(elements)
53
+ elements.each do |element|
54
+ rename_key(element) if element.is_a?(Prism::AssocNode) && label?(element.key)
55
+ end
56
+ end
57
+
58
+ def label?(key)
59
+ key.is_a?(Prism::SymbolNode) && !key.closing.nil? && key.closing.end_with?(":")
60
+ end
61
+
62
+ # A shorthand pair (`{ x: }`) reads its value from the key, so the value is
63
+ # written out to stay what it was.
64
+ def rename_key(pair)
65
+ key = pair.key
66
+ replacement = "#{RENAMED_KEY}:"
67
+ replacement += " #{key.unescaped}" if pair.value.is_a?(Prism::ImplicitNode)
68
+
69
+ add_mutation(
70
+ offset: key.location.start_offset,
71
+ length: key.location.length,
72
+ replacement: replacement,
73
+ node: key
74
+ )
75
+ end
25
76
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Take the tail of a list with `drop` instead of a range index:
6
+ # `list[n..-1]` and the endless `list[n..]` / `list[n...]` become
7
+ # `list.drop(n)`.
8
+ #
9
+ # The two differ only past the end: a start beyond the last element gives
10
+ # `nil` from the index and an empty array from `drop`. A survivor means no
11
+ # test reaches that boundary.
12
+ #
13
+ # A literal start of zero is skipped, never being out of range, and so is a
14
+ # negative literal, which `drop` rejects with ArgumentError. A range that
15
+ # stops short of the last element (`n...-1`, `n..-2`) is no tail at all. A
16
+ # read in void statement position is left to statement_deletion, and index
17
+ # writes are another method altogether.
18
+ #
19
+ # The receiver's type is unknown here: on a String the mutant raises
20
+ # NoMethodError, which any test reaching the line kills.
21
+ class Evilution::Mutator::Operator::IndexRangeToDrop < Evilution::Mutator::Base
22
+ LAST_INDEX = -1
23
+ private_constant :LAST_INDEX
24
+
25
+ def call(subject, **)
26
+ @void_statements = Set.new
27
+ super
28
+ end
29
+
30
+ def visit_statements_node(node)
31
+ @void_statements.merge(node.body[...-1])
32
+ super
33
+ end
34
+
35
+ def visit_call_node(node)
36
+ start = tail_start(node)
37
+ replace_span(node: node, target: node, replacement: drop_call(node, start)) if start
38
+ super
39
+ end
40
+
41
+ private
42
+
43
+ # Where the tail begins, when `node` reads from there to the last element.
44
+ # A beginless range has no start to hand to `drop`, and answers nil too.
45
+ def tail_start(node)
46
+ return unless node.name == :[] && !@void_statements.include?(node)
47
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [Prism::RangeNode => range]]
48
+
49
+ range.left if to_the_end?(range) && droppable_start?(range.left)
50
+ end
51
+
52
+ def to_the_end?(range)
53
+ stop = range.right
54
+ return true if stop.nil?
55
+
56
+ !range.exclude_end? && stop.is_a?(Prism::IntegerNode) && stop.value == LAST_INDEX
57
+ end
58
+
59
+ def droppable_start?(start)
60
+ !start.is_a?(Prism::IntegerNode) || start.value.positive?
61
+ end
62
+
63
+ def drop_call(node, start)
64
+ operator = node.safe_navigation? ? "&." : "."
65
+ "#{source_of(node.receiver)}#{operator}drop(#{source_of(start)})"
66
+ end
67
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Send an index read to `self` instead of its receiver: `hash[key]` becomes
6
+ # `self[key]`, arguments untouched.
7
+ #
8
+ # The survivor it targets lives in a class that defines its own `#[]` — a
9
+ # collection or a wrapper that also holds the thing it indexes — where the
10
+ # tests never tell the two lookups apart. Anywhere else the mutant raises
11
+ # NoMethodError, which any test reaching the line kills.
12
+ #
13
+ # Index writes are left alone: `a[b] = c` is a different method, and the
14
+ # operator-write forms (`a[b] ||= c`) are not calls at all.
15
+ class Evilution::Mutator::Operator::IndexReceiverToSelf < Evilution::Mutator::Base
16
+ def visit_call_node(node)
17
+ replace_span(node: node, target: node.receiver, replacement: "self") if node.name == :[]
18
+ super
19
+ end
20
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Ask whether the key is there instead of reading it: `config[:size]`
6
+ # becomes `config.key?(:size)`.
7
+ #
8
+ # The value turns into a boolean. A survivor means the tests only notice
9
+ # that something came back — a condition taken, a truthy result — and never
10
+ # what the lookup returned.
11
+ #
12
+ # Only a read with one plain argument qualifies, `key?` taking exactly one.
13
+ # An integer or range index is skipped: it points at an Array or a String,
14
+ # which has no `key?`, so the mutant would only raise NoMethodError. A read
15
+ # in void statement position is left to statement_deletion, its value being
16
+ # discarded either way. Index writes are another method altogether.
17
+ class Evilution::Mutator::Operator::IndexToKeyPredicate < Evilution::Mutator::Base
18
+ # Argument shapes `key?` cannot take as its one key.
19
+ NON_KEY_ARGUMENTS = [
20
+ Prism::SplatNode,
21
+ Prism::KeywordHashNode,
22
+ Prism::BlockArgumentNode,
23
+ Prism::ForwardingArgumentsNode,
24
+ Prism::IntegerNode,
25
+ Prism::RangeNode
26
+ ].freeze
27
+ private_constant :NON_KEY_ARGUMENTS
28
+
29
+ def call(subject, **)
30
+ @void_statements = Set.new
31
+ super
32
+ end
33
+
34
+ def visit_statements_node(node)
35
+ @void_statements.merge(node.body[...-1])
36
+ super
37
+ end
38
+
39
+ def visit_call_node(node)
40
+ key = key_argument(node)
41
+ replace_span(node: node, target: node, replacement: key_predicate(node, key)) if key
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ def key_argument(node)
48
+ return unless node.name == :[] && !@void_statements.include?(node)
49
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
50
+
51
+ argument unless NON_KEY_ARGUMENTS.any? { |type| argument.is_a?(type) }
52
+ end
53
+
54
+ def key_predicate(node, key)
55
+ operator = node.safe_navigation? ? "&." : "."
56
+ "#{source_of(node.receiver)}#{operator}key?(#{source_of(key)})"
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace an index write with the value it assigns: `cache[key] = value`
6
+ # becomes `value`.
7
+ #
8
+ # An index write evaluates to the assigned value, so the mutant returns just
9
+ # what the original did — only the store is gone. A survivor means the tests
10
+ # check what was computed and never that it was kept. That is what sets it
11
+ # apart from deleting the statement or turning it into `nil`, both of which
12
+ # also change the result.
13
+ #
14
+ # The result is kept for the assignment syntax only. The method-call
15
+ # spelling is mutated as well, but there it may change: `cache.[]=(key,
16
+ # value)` returns whatever `[]=` returns, and `cache&.[]=(key, value)` is
17
+ # `nil`, with `value` never evaluated, when the receiver is `nil`.
18
+ #
19
+ # A write in void statement position is skipped: with its value discarded,
20
+ # the mutant is statement_deletion's. Operator writes (`a[b] ||= c`) and
21
+ # index targets of a multiple assignment are not calls and are left alone.
22
+ class Evilution::Mutator::Operator::IndexWriteToValue < Evilution::Mutator::Base
23
+ def call(subject, **)
24
+ @void_statements = Set.new
25
+ super
26
+ end
27
+
28
+ def visit_statements_node(node)
29
+ @void_statements.merge(node.body[...-1])
30
+ super
31
+ end
32
+
33
+ def visit_call_node(node)
34
+ promote_child(node, argument_nodes(node).last) if node.name == :[]= && !@void_statements.include?(node)
35
+ super
36
+ end
37
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Turn a division into `fdiv`: `a / b` becomes `a.fdiv(b)`.
6
+ #
7
+ # Between integers `/` drops the remainder while `fdiv` keeps it as a float,
8
+ # and for exact types such as Rational or BigDecimal `fdiv` falls back to
9
+ # Float. A survivor means no example divides values that leave a remainder,
10
+ # or none checks what happens to it.
11
+ #
12
+ # A float literal on either side is skipped: float division and `fdiv` give
13
+ # the same answer, so the mutant would change nothing.
14
+ class Evilution::Mutator::Operator::IntegerDivisionToFdiv < Evilution::Mutator::Base
15
+ # Receivers that `.fdiv` can follow directly. Anything else is an operator
16
+ # expression, which `.fdiv` would bind into, so it is grouped first.
17
+ SIMPLE_RECEIVER_TYPES = [
18
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
19
+ Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode,
20
+ Prism::IntegerNode, Prism::ParenthesesNode, Prism::SelfNode
21
+ ].freeze
22
+
23
+ # A method name, as opposed to an operator such as `*` or `-@`.
24
+ METHOD_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
25
+
26
+ def visit_call_node(node)
27
+ replace_division(node) if division?(node)
28
+ super
29
+ end
30
+
31
+ private
32
+
33
+ # The explicit form (`a./(b)`) is left alone: it is rare, and rewriting it
34
+ # gains nothing over the operator form. The operator form always has a
35
+ # receiver and exactly one divisor.
36
+ def division?(node)
37
+ return false unless node.name == :/ && node.call_operator_loc.nil?
38
+
39
+ [node.receiver, *node.arguments.arguments].none?(Prism::FloatNode)
40
+ end
41
+
42
+ def replace_division(node)
43
+ divisor = node.arguments.arguments.first
44
+
45
+ add_mutation(
46
+ offset: node.location.start_offset,
47
+ length: node.location.length,
48
+ replacement: "#{receiver_text(node.receiver)}.fdiv(#{divisor.slice})",
49
+ node: node
50
+ )
51
+ end
52
+
53
+ def receiver_text(receiver)
54
+ simple_receiver?(receiver) ? receiver.slice : "(#{receiver.slice})"
55
+ end
56
+
57
+ def simple_receiver?(receiver)
58
+ return true if SIMPLE_RECEIVER_TYPES.include?(receiver.class)
59
+
60
+ receiver.is_a?(Prism::CallNode) && receiver.name.to_s.match?(METHOD_NAME)
61
+ end
62
+ end
@@ -3,23 +3,53 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::IntegerLiteral < Evilution::Mutator::Base
6
- def visit_integer_node(node)
7
- if node.value.zero?
8
- add_mutation_with_replacement(node, "1")
9
- elsif node.value == 1
10
- add_mutation_with_replacement(node, "0")
11
- else
12
- add_mutation_with_replacement(node, "0")
13
- add_mutation_with_replacement(node, (node.value + 1).to_s)
14
- end
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
8
+ # One value past each integer width, keyed by the boundary it crosses:
9
+ # int8, uint8, int16, uint16, int32, uint32, int64. A literal is replaced
10
+ # with the sentinel of the first boundary above its magnitude, so a
11
+ # survivor means nothing checks that the value still fits its width. The
12
+ # sentinels are safe primes: arithmetic, masking or shifting does not land
13
+ # on one by coincidence.
14
+ WIDTH_SENTINELS = {
15
+ 2**7 => 167,
16
+ 2**8 => 467,
17
+ 2**15 => 55_487,
18
+ 2**16 => 108_503,
19
+ 2**31 => 2_667_278_543,
20
+ 2**32 => 7_980_081_959,
21
+ 2**63 => 15_508_464_536_481_899_903
22
+ }.freeze
15
23
 
24
+ def visit_integer_node(node)
25
+ replacements_for(node.value).each { |value| add_mutation_with_replacement(node, value.to_s) }
16
26
  add_mutation_with_replacement(node, "nil")
17
27
 
18
28
  super
19
29
  end
20
30
 
31
+ # A complex literal (`5i`) is replaced as a whole by ComplexLiteral. Its
32
+ # numeric part is not a literal of its own: `nili` is not a value.
33
+ def visit_imaginary_node(_node); end
34
+
21
35
  private
22
36
 
37
+ def replacements_for(value)
38
+ [*neighbours_of(value), width_sentinel_for(value)].compact
39
+ end
40
+
41
+ def neighbours_of(value)
42
+ return [1, -1] if value.zero?
43
+ return [0] if value == 1
44
+
45
+ [0, value + 1, value - 1].uniq
46
+ end
47
+
48
+ def width_sentinel_for(value)
49
+ _boundary, sentinel = WIDTH_SENTINELS.find { |boundary, _| boundary > value.abs }
50
+ sentinel
51
+ end
52
+
23
53
  def add_mutation_with_replacement(node, replacement)
24
54
  add_mutation(offset: node.location.start_offset, length: node.location.length, replacement:, node:)
25
55
  end
@@ -8,7 +8,7 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
8
8
  if params
9
9
  mutate_optional_keyword_defaults(params)
10
10
  mutate_optional_keyword_removal(params)
11
- mutate_keyword_rest_removal(params)
11
+ mutate_keyword_rest_removal(node)
12
12
  end
13
13
 
14
14
  super
@@ -51,9 +51,11 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
51
51
  end
52
52
  end
53
53
 
54
- def mutate_keyword_rest_removal(params)
54
+ def mutate_keyword_rest_removal(node)
55
+ params = node.parameters
55
56
  kr = params.keyword_rest
56
57
  return unless kr.is_a?(Prism::KeywordRestParameterNode)
58
+ return if anonymous_rest_used?(node.body)
57
59
 
58
60
  all_params = collect_all_params(params)
59
61
  if all_params.length < 2
@@ -63,6 +65,27 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
63
65
  end
64
66
  end
65
67
 
68
+ # An anonymous `**` in the body (`bar(**)`, `{ ** }`) only parses while the
69
+ # signature declares one, so removing it there would leave the body
70
+ # unparseable. Finding one also tells the rest is anonymous: Ruby rejects it
71
+ # next to a named rest. A named rest is safe to remove: without it the body
72
+ # reads an undefined name, which parses and fails at runtime.
73
+ def anonymous_rest_used?(body)
74
+ return false if body.nil?
75
+
76
+ uses_anonymous_rest?(body)
77
+ end
78
+
79
+ # A nested def is not searched: an anonymous `**` inside it belongs to that
80
+ # method's own signature. Blocks and lambdas share the enclosing method's
81
+ # parameters, so they are.
82
+ def uses_anonymous_rest?(node)
83
+ return false if node.is_a?(Prism::DefNode)
84
+ return true if node.is_a?(Prism::AssocSplatNode) && node.value.nil?
85
+
86
+ node.compact_child_nodes.any? { |child| uses_anonymous_rest?(child) }
87
+ end
88
+
66
89
  def emit_remove_only_kr(kr)
67
90
  add_mutation(
68
91
  offset: kr.location.start_offset,