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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../../ast/regexp_pattern"
5
+
6
+ # Swap a regexp quantifier between "zero or more" and "one or more": `*`
7
+ # becomes `+` and `+` becomes `*`, keeping a lazy (`*?`) or possessive (`*+`)
8
+ # quantifier lazy or possessive.
9
+ #
10
+ # Only the empty case changes: `/\A\d*\z/` accepts "" and `/\A\d+\z/` does
11
+ # not. A survivor means no example checks how the pattern treats an absent
12
+ # repetition. RegexSimplification's quantifier removal changes the many case
13
+ # too; this isolates the empty one.
14
+ #
15
+ # Making a recursive `\g<name>` call mandatory can make the pattern recurse
16
+ # forever, so each mutant is compiled first and dropped if it does not.
17
+ class Evilution::Mutator::Operator::RegexpQuantifierMinimumSwap < Evilution::Mutator::Base
18
+ SWAPS = {
19
+ zero_or_more: "+", zero_or_more_reluctant: "+?", zero_or_more_possessive: "++",
20
+ one_or_more: "*", one_or_more_reluctant: "*?", one_or_more_possessive: "*+"
21
+ }.freeze
22
+
23
+ def visit_regular_expression_node(node)
24
+ pattern = Evilution::AST::RegexpPattern.parse(node)
25
+ pattern.tokens.each { |token| swap(node, pattern, token) } if pattern
26
+ super
27
+ end
28
+
29
+ private
30
+
31
+ def swap(node, pattern, token)
32
+ return unless token.type == :quantifier
33
+
34
+ replacement = SWAPS[token.token]
35
+ return if replacement.nil?
36
+ return unless pattern.compiles?(token.start_offset, token.end_offset, replacement)
37
+
38
+ add_mutation(
39
+ offset: token.start_offset,
40
+ length: token.end_offset - token.start_offset,
41
+ replacement: replacement,
42
+ node: node
43
+ )
44
+ end
45
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Widen what a rescue clause catches: `rescue KeyError` becomes
6
+ # `rescue StandardError` and `rescue Exception`.
7
+ #
8
+ # A survivor means no example raises a sibling error that has to get past
9
+ # the handler, so nothing shows the clause needs to be as narrow as it is.
10
+ #
11
+ # A bare `rescue` is `rescue StandardError`, so the two are never offered as
12
+ # mutants of each other; both go to `Exception` only. So does a clause naming
13
+ # a core class outside `StandardError` (`Interrupt`, `LoadError`), which
14
+ # `StandardError` would not widen but replace. A class of the program's own
15
+ # is taken to be a `StandardError`. A clause that already names `Exception`
16
+ # cannot be widened. A rescue modifier (`x rescue y`) takes no class and is
17
+ # left alone.
18
+ class Evilution::Mutator::Operator::RescueClassWidening < Evilution::Mutator::Base
19
+ WIDER = %w[StandardError Exception].freeze
20
+
21
+ # The core classes that descend from Exception but not from StandardError.
22
+ OUTSIDE_STANDARD_ERROR = %w[
23
+ NoMemoryError ScriptError LoadError NotImplementedError SyntaxError SecurityError SignalException Interrupt
24
+ SystemExit SystemStackError
25
+ ].freeze
26
+
27
+ def visit_rescue_node(node)
28
+ wider_than(node.exceptions).each { |name| widen(node, name) }
29
+ super
30
+ end
31
+
32
+ private
33
+
34
+ # The classes of WIDER above everything the clause lists.
35
+ def wider_than(exceptions)
36
+ listed = exceptions.map { |exception| exception.slice.delete_prefix("::") }
37
+ return [] if listed.include?(WIDER.last)
38
+ return [WIDER.last] if listed.empty? || listed.include?(WIDER.first) || listed.intersect?(OUTSIDE_STANDARD_ERROR)
39
+
40
+ WIDER
41
+ end
42
+
43
+ def widen(node, name)
44
+ exceptions = node.exceptions
45
+ return insert_after_keyword(node, name) if exceptions.empty?
46
+
47
+ offset = exceptions.first.location.start_offset
48
+ add_mutation(offset:, length: exceptions.last.location.end_offset - offset, replacement: name, node:)
49
+ end
50
+
51
+ def insert_after_keyword(node, name)
52
+ add_mutation(offset: node.keyword_loc.end_offset, length: 0, replacement: " #{name}", node:)
53
+ end
54
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../rescue_handlers"
5
+
6
+ # Move the else clause of a rescue into the code it guards, keeping the rescue:
7
+ #
8
+ # begin begin
9
+ # fetch(id) fetch(id)
10
+ # rescue StandardError -> notify
11
+ # default rescue StandardError
12
+ # else default
13
+ # notify end
14
+ # end
15
+ #
16
+ # The else code still runs only when the body succeeded; the one thing that
17
+ # changes is that an error it raises is now caught by the rescue, where an
18
+ # else clause is not protected. A survivor means no test lets the else code
19
+ # fail in a way the rescue would swallow.
20
+ #
21
+ # Only a rescue with at least one broad clause — a bare `rescue`, or one
22
+ # naming StandardError or Exception — is mutated. Under a rescue for specific
23
+ # classes the moved code changes behaviour only if it raises one of them,
24
+ # which it rarely does, so those mutants would mostly survive without saying
25
+ # anything. An empty else clause and a rescue around an empty body are
26
+ # skipped; ensure clauses and every rescue clause stay as written.
27
+ class Evilution::Mutator::Operator::RescueElseConcatenation < Evilution::Mutator::Base
28
+ BROAD_EXCEPTIONS = %w[StandardError ::StandardError Exception ::Exception].freeze
29
+
30
+ def visit_begin_node(node)
31
+ else_clause = node.else_clause
32
+ move_else(node, else_clause) if node.statements && else_clause && else_clause.statements && broad_rescue?(node)
33
+ super
34
+ end
35
+
36
+ private
37
+
38
+ def broad_rescue?(node)
39
+ Evilution::Mutator::RescueHandlers.new(node).clauses.any? do |clause|
40
+ clause.exceptions.empty? || clause.exceptions.any? { |exception| BROAD_EXCEPTIONS.include?(exception.slice) }
41
+ end
42
+ end
43
+
44
+ # One edit from the end of the body to the end of the else body: the else
45
+ # statements go first, then the rescue clauses as they were, with the
46
+ # whitespace (and a one-line `;`) that led into `else` trimmed off.
47
+ def move_else(node, else_clause)
48
+ body_end = node.statements.location.end_offset
49
+ continuation = Evilution::Mutator::RescueHandlers.continuation(@file_source, node.statements)
50
+
51
+ add_mutation(
52
+ offset: body_end,
53
+ length: else_clause.statements.location.end_offset - body_end,
54
+ replacement: "#{continuation}#{else_clause.statements.slice}#{rescue_clauses_text(body_end, else_clause)}",
55
+ node: else_clause
56
+ )
57
+ end
58
+
59
+ def rescue_clauses_text(body_end, else_clause)
60
+ byteslice_source(body_end, else_clause.else_keyword_loc.start_offset - body_end).rstrip.delete_suffix(";")
61
+ end
62
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../rescue_handlers"
5
+
6
+ # Run a rescue handler after the code it protects as well, keeping the rescue:
7
+ #
8
+ # begin begin
9
+ # fetch(id) fetch(id)
10
+ # rescue NotFound -> rollback
11
+ # rollback rescue NotFound
12
+ # end rollback
13
+ # end
14
+ #
15
+ # The failure path is untouched; only a successful run changes, now also
16
+ # doing what the handler does. A survivor means the handler is harmless when
17
+ # nothing failed — the tests never show it is meant for the failure only.
18
+ # Dropping the rescue as well would repeat RescueRemoval and hide this signal
19
+ # behind any test of the failure path.
20
+ #
21
+ # With several rescue clauses each handler is used in its own mutant. An empty
22
+ # handler adds nothing and is skipped, as is one that only produces a value
23
+ # (`nil`, a literal, a variable, a bare `next`): run after the body it changes
24
+ # nothing but the return value, which RescueHandlerPromotion already probes.
25
+ # Handlers that only work inside the rescue are skipped too (see
26
+ # Mutator::RescueHandlers). A rescue modifier sits in value position, where a
27
+ # statement sequence does not fit, and is left alone.
28
+ class Evilution::Mutator::Operator::RescueHandlerConcatenation < Evilution::Mutator::Base
29
+ VALUE_TYPES = [
30
+ Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::IntegerNode, Prism::FloatNode,
31
+ Prism::StringNode, Prism::SymbolNode, Prism::SelfNode, Prism::ConstantReadNode,
32
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode
33
+ ].freeze
34
+
35
+ def visit_begin_node(node)
36
+ append_handlers(node) if node.rescue_clause && node.statements
37
+ super
38
+ end
39
+
40
+ private
41
+
42
+ def append_handlers(node)
43
+ body_end = node.statements.location.end_offset
44
+ separator = Evilution::Mutator::RescueHandlers.continuation(@file_source, node.statements)
45
+
46
+ Evilution::Mutator::RescueHandlers.new(node).clauses.each do |clause|
47
+ handler = clause.statements
48
+ next if handler.nil? || value_only?(handler)
49
+ next unless Evilution::Mutator::RescueHandlers.movable?(handler, clause.reference)
50
+
51
+ add_mutation(offset: body_end, length: 0, replacement: "#{separator}#{handler.slice}", node: clause)
52
+ end
53
+ end
54
+
55
+ def value_only?(handler)
56
+ handler.body.all? { |statement| value?(statement) }
57
+ end
58
+
59
+ # A method call may act, so only literals, plain reads and a bare `next` /
60
+ # `return` count.
61
+ def value?(statement)
62
+ case statement
63
+ when *VALUE_TYPES then true
64
+ when Prism::ArrayNode, Prism::HashNode then statement.elements.empty?
65
+ when Prism::NextNode, Prism::ReturnNode then statement.arguments.nil?
66
+ else false
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../rescue_handlers"
5
+
6
+ # Run a rescue handler instead of the code it protects:
7
+ # `begin; fetch(id); rescue NotFound; default; end` becomes
8
+ # `begin; default; end`, a method-level rescue does the same to the method
9
+ # body, and `fetch(id) rescue default` becomes `default`.
10
+ #
11
+ # The happy path never runs, so a survivor means the tests accept the
12
+ # fallback value where the real result was expected — they never check what
13
+ # the protected code returns.
14
+ #
15
+ # With several rescue clauses each handler is promoted in its own mutant. An
16
+ # else clause goes with the body, since it only runs when nothing is rescued;
17
+ # an ensure clause stays. An empty handler becomes `nil`, what a swallowed
18
+ # error returns.
19
+ #
20
+ # Handlers that only make sense inside the rescue are skipped, since promoted
21
+ # they fail for that reason alone; Mutator::RescueHandlers holds the rule.
22
+ class Evilution::Mutator::Operator::RescueHandlerPromotion < Evilution::Mutator::Base
23
+ def visit_begin_node(node)
24
+ promote_handlers(node) if node.rescue_clause && node.statements
25
+ super
26
+ end
27
+
28
+ def visit_rescue_modifier_node(node)
29
+ fallback = node.rescue_expression
30
+ replace(node, fallback.slice, node) if Evilution::Mutator::RescueHandlers.movable?(fallback, nil)
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ def promote_handlers(node)
37
+ start_offset = node.statements.location.start_offset
38
+ end_offset = protected_end(node)
39
+
40
+ Evilution::Mutator::RescueHandlers.new(node).clauses.each do |clause|
41
+ next unless Evilution::Mutator::RescueHandlers.movable?(clause.statements, clause.reference)
42
+
43
+ handler = clause.statements ? clause.statements.slice : "nil"
44
+ add_mutation(offset: start_offset, length: end_offset - start_offset, replacement: handler, node: clause)
45
+ end
46
+ end
47
+
48
+ # The body, every rescue clause and an else clause are replaced; an ensure
49
+ # clause and the closing `end` stay. A rescue clause's location runs on
50
+ # through the clauses after it, and an else clause's up to the keyword that
51
+ # follows it, so the span ends at the last clause's own content.
52
+ def protected_end(node)
53
+ else_clause = node.else_clause
54
+ return Evilution::Mutator::RescueHandlers.new(node).clauses.last.location.end_offset if else_clause.nil?
55
+
56
+ return else_clause.statements.location.end_offset if else_clause.statements
57
+
58
+ else_clause.else_keyword_loc.end_offset
59
+ end
60
+
61
+ def replace(target, replacement, node)
62
+ location = target.location
63
+ add_mutation(offset: location.start_offset, length: location.length, replacement: replacement, node: node)
64
+ end
65
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../rescue_handlers"
5
+
6
+ # Drop the `return` keyword and keep its value: `return :neg if x.negative?`
7
+ # becomes `:neg if x.negative?`.
8
+ #
9
+ # Control flow continues past the point where the method used to leave, so a
10
+ # survivor means no test notices the code after a guard clause or an early
11
+ # return running anyway. ReturnValueRemoval keeps the early exit and drops the
12
+ # value; this keeps the value and drops the exit. Several values become an
13
+ # array, as `return` would make them: `return a, b` becomes `[a, b]`.
14
+ #
15
+ # A return in tail position — the method's last statement, the last statement
16
+ # of a branch that is itself in tail position, or the tail of a lambda — is
17
+ # skipped: its value is the result either way. A return inside a block always
18
+ # leaves the method, so it is never tail position. A bare `return` has no
19
+ # value to keep.
20
+ class Evilution::Mutator::Operator::ReturnKeywordRemoval < Evilution::Mutator::Base
21
+ # For each node that passes a value on, the parts whose value becomes its
22
+ # own. With an else clause a begin body's value is not the result, the
23
+ # else's is; each rescue handler's value can be the result too.
24
+ TAIL_PARTS = {
25
+ Prism::StatementsNode => ->(node) { [node.body.last] },
26
+ Prism::ParenthesesNode => ->(node) { [node.body] },
27
+ Prism::ElseNode => ->(node) { [node.statements] },
28
+ Prism::IfNode => ->(node) { [node.statements, node.subsequent] },
29
+ Prism::UnlessNode => ->(node) { [node.statements, node.else_clause] },
30
+ Prism::CaseNode => ->(node) { [*node.conditions.map(&:statements), node.else_clause] },
31
+ Prism::CaseMatchNode => ->(node) { [*node.conditions.map(&:statements), node.else_clause] },
32
+ Prism::BeginNode => lambda { |node|
33
+ [node.else_clause || node.statements, *Evilution::Mutator::RescueHandlers.new(node).clauses.map(&:statements)]
34
+ }
35
+ }.freeze
36
+
37
+ def initialize(**options)
38
+ super
39
+ @tail_returns = Set.new.compare_by_identity
40
+ end
41
+
42
+ def visit_def_node(node)
43
+ @tail_returns.merge(tail_returns(node.body))
44
+ super
45
+ end
46
+
47
+ def visit_lambda_node(node)
48
+ @tail_returns.merge(tail_returns(node.body))
49
+ super
50
+ end
51
+
52
+ def visit_return_node(node)
53
+ drop_keyword(node) if node.arguments && !@tail_returns.include?(node)
54
+ super
55
+ end
56
+
57
+ private
58
+
59
+ def drop_keyword(node)
60
+ arguments = node.arguments.arguments
61
+ value = if arguments.length == 1 && !arguments.first.is_a?(Prism::SplatNode)
62
+ arguments.first.slice
63
+ else
64
+ "[#{arguments.map(&:slice).join(", ")}]"
65
+ end
66
+
67
+ add_mutation(offset: node.location.start_offset, length: node.location.length, replacement: value, node: node)
68
+ end
69
+
70
+ # The returns whose value is the value of `node` itself.
71
+ def tail_returns(node)
72
+ return [node] if node.is_a?(Prism::ReturnNode)
73
+
74
+ parts = TAIL_PARTS[node.class]
75
+ return [] if parts.nil?
76
+
77
+ parts.call(node).compact.flat_map { |part| tail_returns(part) }
78
+ end
79
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Turn a rightward pattern match into a pattern predicate: `value => [a, b]`
6
+ # becomes `value in [a, b]`.
7
+ #
8
+ # On a mismatch the original raises NoMatchingPatternError, while the predicate
9
+ # returns false and leaves the pattern's variables nil. A survivor means no
10
+ # example feeds the match a value it rejects, so the failure mode is untested.
11
+ class Evilution::Mutator::Operator::RightwardAssignment < Evilution::Mutator::Base
12
+ def visit_match_required_node(node)
13
+ replace_operator(node) unless irrefutable?(node.pattern)
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def replace_operator(node)
20
+ location = node.operator_loc
21
+
22
+ add_mutation(
23
+ offset: location.start_offset,
24
+ length: location.length,
25
+ replacement: keyword_for(node),
26
+ node: node
27
+ )
28
+ end
29
+
30
+ # `=>` is punctuation and may touch its neighbours (`value=>Integer`), but
31
+ # `in` is a word: written flush against them it would merge into a single
32
+ # name. A space is added on each side that has none.
33
+ def keyword_for(node)
34
+ location = node.operator_loc
35
+ before = node.value.location.end_offset == location.start_offset ? " " : ""
36
+ after = node.pattern.location.start_offset == location.end_offset ? " " : ""
37
+
38
+ "#{before}in#{after}"
39
+ end
40
+
41
+ # A bare name (`value => captured`, `value => _`) captures any value, so the
42
+ # match can never raise and the predicate form would behave the same.
43
+ def irrefutable?(pattern)
44
+ pattern.is_a?(Prism::LocalVariableTargetNode)
45
+ end
46
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../../ast/round_half_mode"
5
+
6
+ # Swap the tie-breaking mode of `round`: `amount.round(2, half: :even)`
7
+ # becomes `half: :up` and `half: :down`.
8
+ #
9
+ # The modes differ only for a value sitting exactly on a tie (`2.5`), so a
10
+ # survivor means no example rounds one — the case banker's rounding in money
11
+ # code exists for.
12
+ #
13
+ # Only a literal mode is swapped; one held in a variable is left alone.
14
+ # Integer, Float, Rational and BigDecimal all read `half:` the same way.
15
+ class Evilution::Mutator::Operator::RoundHalfModeSwap < Evilution::Mutator::Base
16
+ def visit_call_node(node)
17
+ mode = Evilution::AST::RoundHalfMode.of(node)
18
+ swap(node, mode) if mode
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def swap(node, mode)
25
+ others = Evilution::AST::RoundHalfMode::MODES - [mode.unescaped.to_sym]
26
+
27
+ others.each do |other|
28
+ add_mutation(
29
+ offset: mode.location.start_offset,
30
+ length: mode.location.length,
31
+ replacement: other.inspect,
32
+ node: node
33
+ )
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../../ast/never_nil"
5
+
6
+ # Replace a plain call with safe navigation: `user.name` becomes
7
+ # `user&.name`, and the same for the `||=`, `&&=` and operator-write forms.
8
+ #
9
+ # The mutant only differs when the receiver is nil: the call is then skipped
10
+ # and the expression is nil, where the plain call raises NoMethodError or
11
+ # runs a method nil has (`nil.to_a` is `[]`). A survivor means no test
12
+ # reaches the call with a nil receiver and checks what comes of it.
13
+ #
14
+ # It touches nearly every call and survives wherever the receiver is never
15
+ # nil, so it is registered for the strict profile only. Receivers that can
16
+ # never be nil (`self`, literals other than `nil`, constants) are skipped,
17
+ # since the mutant is equivalent there.
18
+ class Evilution::Mutator::Operator::SafeNavigationInsertion < Evilution::Mutator::Base
19
+ # A constant could hold nil, but one that receives a call does not in
20
+ # practice.
21
+ CONSTANTS = [Prism::ConstantReadNode, Prism::ConstantPathNode].freeze
22
+ private_constant :CONSTANTS
23
+
24
+ def visit_call_node(node)
25
+ mutate_plain_call(node)
26
+ super
27
+ end
28
+
29
+ def visit_call_or_write_node(node)
30
+ mutate_plain_call(node)
31
+ super
32
+ end
33
+
34
+ def visit_call_and_write_node(node)
35
+ mutate_plain_call(node)
36
+ super
37
+ end
38
+
39
+ def visit_call_operator_write_node(node)
40
+ mutate_plain_call(node)
41
+ super
42
+ end
43
+
44
+ private
45
+
46
+ def mutate_plain_call(node)
47
+ operator = node.call_operator_loc
48
+ return unless operator && operator.slice == "."
49
+ return if never_nil?(node.receiver)
50
+
51
+ add_mutation(offset: operator.start_offset, length: operator.length, replacement: "&.", node: node)
52
+ end
53
+
54
+ def never_nil?(receiver)
55
+ Evilution::AST::NeverNil.node?(receiver) || CONSTANTS.include?(receiver.class)
56
+ end
57
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../operator"
4
+ require_relative "../../ast/never_nil"
4
5
 
5
6
  # Replace safe navigation with a plain call: `user&.name` becomes
6
7
  # `user.name`, and the same for the `||=`, `&&=` and operator-write forms.
@@ -12,29 +13,6 @@ require_relative "../operator"
12
13
  # Receivers that can never be nil — `self` and literals — are skipped, since
13
14
  # the mutant is equivalent there.
14
15
  class Evilution::Mutator::Operator::SafeNavigationRemoval < Evilution::Mutator::Base
15
- NEVER_NIL_RECEIVERS = [
16
- Prism::SelfNode,
17
- Prism::StringNode,
18
- Prism::InterpolatedStringNode,
19
- Prism::XStringNode,
20
- Prism::InterpolatedXStringNode,
21
- Prism::SymbolNode,
22
- Prism::InterpolatedSymbolNode,
23
- Prism::IntegerNode,
24
- Prism::FloatNode,
25
- Prism::RationalNode,
26
- Prism::ImaginaryNode,
27
- Prism::ArrayNode,
28
- Prism::HashNode,
29
- Prism::RangeNode,
30
- Prism::RegularExpressionNode,
31
- Prism::InterpolatedRegularExpressionNode,
32
- Prism::TrueNode,
33
- Prism::FalseNode,
34
- Prism::LambdaNode
35
- ].freeze
36
- private_constant :NEVER_NIL_RECEIVERS
37
-
38
16
  def visit_call_node(node)
39
17
  mutate_safe_navigation(node)
40
18
  super
@@ -59,7 +37,7 @@ class Evilution::Mutator::Operator::SafeNavigationRemoval < Evilution::Mutator::
59
37
 
60
38
  def mutate_safe_navigation(node)
61
39
  return unless node.safe_navigation?
62
- return if NEVER_NIL_RECEIVERS.any? { |type| node.receiver.is_a?(type) }
40
+ return if Evilution::AST::NeverNil.node?(node.receiver)
63
41
 
64
42
  operator = node.call_operator_loc
65
43
  add_mutation(offset: operator.start_offset, length: operator.length, replacement: ".", node: node)
@@ -43,6 +43,8 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
43
43
  end_with?: [:start_with?],
44
44
  ceil: [:floor],
45
45
  floor: [:ceil],
46
+ round: %i[floor ceil],
47
+ truncate: [:floor],
46
48
  transform_keys: [:transform_values],
47
49
  transform_values: [:transform_keys],
48
50
  append: [:prepend],
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Make a short-circuit operator evaluate both sides: `a && b` becomes
6
+ # `a & b`, `a || b` becomes `a | b`.
7
+ #
8
+ # A survivor means no example depends on the right side being skipped: it
9
+ # guards nothing (a nil receiver, a side effect), and both sides are plain
10
+ # booleans, since `&` and `|` give another value for anything else.
11
+ #
12
+ # `&` and `|` bind tighter than `&&` and `||`, so an operand that would
13
+ # regroup (`a == 1 && b` is not `a == 1 & b`) is parenthesized.
14
+ class Evilution::Mutator::Operator::ShortCircuitToEager < Evilution::Mutator::Base
15
+ def visit_and_node(node)
16
+ mutate_to_eager(node, "&")
17
+ super
18
+ end
19
+
20
+ def visit_or_node(node)
21
+ mutate_to_eager(node, "|")
22
+ super
23
+ end
24
+
25
+ private
26
+
27
+ # An operand that only jumps (`a || return`) has no value to pass on; the
28
+ # result does not parse and is dropped.
29
+ def mutate_to_eager(node, operator)
30
+ replacement = "#{receiver_source(node.left)} #{operator} #{receiver_source(node.right)}"
31
+
32
+ replace_span(node:, target: node, replacement:)
33
+ end
34
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Take the wait out of a sleep whose duration is not a literal:
6
+ # `sleep delay` becomes `sleep 0`.
7
+ #
8
+ # A survivor means the pause decides nothing an example observes. A killed
9
+ # mutant means the suite depends on wall-clock time, which is worth knowing
10
+ # too.
11
+ #
12
+ # A literal duration (`sleep 5`, `sleep 0.5`) is left to the literal
13
+ # operators, which already take it to zero. Only `Kernel#sleep` is touched: a
14
+ # `sleep` sent to another receiver may be something else.
15
+ class Evilution::Mutator::Operator::SleepToZero < Evilution::Mutator::Base
16
+ LITERAL_DURATIONS = [Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode].freeze
17
+ KERNEL = %w[Kernel ::Kernel].freeze
18
+
19
+ def visit_call_node(node)
20
+ duration = duration_of(node)
21
+ add_mutation(offset: duration.location.start_offset, length: duration.location.length, replacement: "0", node:) if duration
22
+
23
+ super
24
+ end
25
+
26
+ private
27
+
28
+ # The single argument of a `sleep` call, when it is one this operator
29
+ # replaces.
30
+ def duration_of(node)
31
+ return unless node.name == :sleep && kernel_call?(node) && node.arguments
32
+
33
+ arguments = node.arguments.arguments
34
+ return unless arguments.one?
35
+
36
+ duration = arguments.first
37
+ duration unless duration.is_a?(Prism::SplatNode) || LITERAL_DURATIONS.include?(duration.class)
38
+ end
39
+
40
+ def kernel_call?(node)
41
+ node.receiver.nil? || KERNEL.include?(node.receiver.slice)
42
+ end
43
+ end