evilution 1.2.0 → 1.4.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 (213) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +100 -0
  3. data/.rubocop_todo.yml +14 -0
  4. data/CHANGELOG.md +124 -0
  5. data/README.md +142 -28
  6. data/docs/architecture.md +123 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -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/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +120 -16
  15. data/lib/evilution/ast/pattern/filter.rb +1 -0
  16. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  17. data/lib/evilution/ast/pattern/parser.rb +15 -15
  18. data/lib/evilution/ast/pattern.rb +2 -0
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/scope_declaration.rb +45 -0
  21. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  22. data/lib/evilution/ast/uncovered_code.rb +88 -0
  23. data/lib/evilution/ast/value_object_definition.rb +25 -0
  24. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  25. data/lib/evilution/baseline/report.rb +66 -0
  26. data/lib/evilution/baseline/spec_failure.rb +38 -0
  27. data/lib/evilution/baseline.rb +101 -42
  28. data/lib/evilution/child_output.rb +1 -1
  29. data/lib/evilution/cli/command.rb +1 -0
  30. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  31. data/lib/evilution/cli/commands.rb +2 -0
  32. data/lib/evilution/cli/dispatcher.rb +2 -0
  33. data/lib/evilution/cli/exit_guard.rb +2 -0
  34. data/lib/evilution/cli/parsed_args.rb +2 -0
  35. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  36. data/lib/evilution/cli/parser/file_args.rb +4 -1
  37. data/lib/evilution/cli/parser/options_builder.rb +4 -1
  38. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  39. data/lib/evilution/cli/parser.rb +1 -0
  40. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  41. data/lib/evilution/cli/printers.rb +2 -0
  42. data/lib/evilution/cli/result.rb +2 -0
  43. data/lib/evilution/cli.rb +8 -2
  44. data/lib/evilution/config/builders.rb +2 -0
  45. data/lib/evilution/config/env_loader.rb +2 -0
  46. data/lib/evilution/config/file_loader.rb +1 -0
  47. data/lib/evilution/config/sources.rb +1 -0
  48. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  49. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  50. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  51. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  52. data/lib/evilution/config/validators/hooks.rb +1 -0
  53. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  54. data/lib/evilution/config/validators/integration.rb +1 -0
  55. data/lib/evilution/config/validators/isolation.rb +1 -0
  56. data/lib/evilution/config/validators/jobs.rb +1 -0
  57. data/lib/evilution/config/validators/preload.rb +1 -0
  58. data/lib/evilution/config/validators/profile.rb +1 -0
  59. data/lib/evilution/config/validators/spec_mappings.rb +3 -1
  60. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  61. data/lib/evilution/config/validators/warmup.rb +17 -0
  62. data/lib/evilution/config/validators.rb +2 -0
  63. data/lib/evilution/config.rb +7 -5
  64. data/lib/evilution/coverage.rb +1 -1
  65. data/lib/evilution/coverage_example_filter.rb +1 -1
  66. data/lib/evilution/diagnostic.rb +1 -1
  67. data/lib/evilution/equivalent/detector.rb +3 -1
  68. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  69. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  70. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  71. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  72. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  73. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  74. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  75. data/lib/evilution/example_filter.rb +46 -1
  76. data/lib/evilution/hooks/registry.rb +2 -1
  77. data/lib/evilution/integration/base.rb +2 -0
  78. data/lib/evilution/integration/known_failures.rb +26 -0
  79. data/lib/evilution/integration/loading/body_call_neutralizer.rb +139 -12
  80. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  81. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  82. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  83. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  84. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  85. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  86. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  87. data/lib/evilution/integration/minitest.rb +45 -6
  88. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  89. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  90. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  91. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  92. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  93. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  94. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  95. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  96. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  97. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  98. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  99. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  100. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  101. data/lib/evilution/integration/rspec/state_guard.rb +10 -1
  102. data/lib/evilution/integration/rspec.rb +22 -4
  103. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  104. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  105. data/lib/evilution/integration/test_unit.rb +37 -9
  106. data/lib/evilution/isolation/fork.rb +2 -1
  107. data/lib/evilution/isolation/in_process.rb +21 -4
  108. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  109. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  110. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  111. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  112. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  113. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  114. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  115. data/lib/evilution/memory/leak_check.rb +27 -3
  116. data/lib/evilution/mutation.rb +8 -3
  117. data/lib/evilution/mutator/base.rb +49 -5
  118. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  119. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  120. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  121. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  122. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  123. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  124. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  125. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  126. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  127. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  128. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  129. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  130. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  131. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  132. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  133. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  134. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  135. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  136. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  137. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  138. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  139. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  140. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  141. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  142. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  143. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  144. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  145. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  146. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  147. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  148. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  149. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  150. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  151. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  152. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  153. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  154. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  155. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  156. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  157. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  158. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  159. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  160. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  161. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  162. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  163. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  164. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  165. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  166. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  167. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  168. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  169. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  170. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  171. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  172. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  173. data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
  174. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  175. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  176. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  177. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  178. data/lib/evilution/mutator/primitives.rb +25 -0
  179. data/lib/evilution/mutator/registry.rb +54 -2
  180. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  181. data/lib/evilution/parallel/pool.rb +1 -0
  182. data/lib/evilution/parallel_db_warning.rb +1 -1
  183. data/lib/evilution/process_supervisor.rb +3 -2
  184. data/lib/evilution/rails_warmup.rb +61 -0
  185. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  186. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  187. data/lib/evilution/reporter/html/report.rb +1 -0
  188. data/lib/evilution/reporter/json/baseline.rb +15 -0
  189. data/lib/evilution/reporter/json.rb +7 -2
  190. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  191. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  192. data/lib/evilution/result/mutation_result.rb +9 -1
  193. data/lib/evilution/result/subject_scorer.rb +1 -0
  194. data/lib/evilution/result/summary.rb +42 -2
  195. data/lib/evilution/runner/baseline_runner.rb +26 -11
  196. data/lib/evilution/runner/canary.rb +2 -57
  197. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  198. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  199. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
  200. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  201. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  202. data/lib/evilution/runner/mutation_executor.rb +1 -1
  203. data/lib/evilution/runner/mutation_planner.rb +11 -2
  204. data/lib/evilution/runner/report_publisher.rb +3 -2
  205. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  206. data/lib/evilution/runner.rb +4 -1
  207. data/lib/evilution/source_ast_cache.rb +1 -1
  208. data/lib/evilution/spec_ast_cache.rb +1 -1
  209. data/lib/evilution/subject.rb +8 -2
  210. data/lib/evilution/version.rb +1 -1
  211. data/lib/evilution.rb +51 -0
  212. data/script/memory_check +62 -16
  213. metadata +108 -4
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace an `Array()` coercion with an array literal: `Array(value)` becomes
6
+ # `[value]`, and the same for `Kernel.Array(value)` / `Kernel::Array(value)`.
7
+ #
8
+ # The two agree only on a plain scalar. `Array(nil)` is `[]` where `[nil]`
9
+ # is not, an array or hash is converted rather than wrapped, and anything
10
+ # responding to `to_ary` / `to_a` is unpacked. A survivor means no test
11
+ # passes nil or a collection through the coercion.
12
+ class Evilution::Mutator::Operator::ArrayCoercionToLiteral < Evilution::Mutator::Base
13
+ def visit_call_node(node)
14
+ rewrite(node) if node.name == :Array && kernel_receiver?(node.receiver) && node.block.nil?
15
+ super
16
+ end
17
+
18
+ private
19
+
20
+ def rewrite(node)
21
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
22
+ return if argument.is_a?(Prism::SplatNode)
23
+
24
+ replace_span(node: node, target: node, replacement: "[#{source_of(argument)}]")
25
+ end
26
+
27
+ def kernel_receiver?(receiver)
28
+ case receiver
29
+ when nil then true
30
+ when Prism::ConstantReadNode then receiver.name == :Kernel
31
+ when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Kernel
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace an attribute or index write with the matching read: `a.foo = b`
6
+ # becomes `a.foo`, `a[i] = b` becomes `a[i]`.
7
+ #
8
+ # A survivor means nothing observes the write. Only positions
9
+ # statement_deletion does not reach are mutated: a write that is one of
10
+ # several statements in a body is already deleted there, and turning it into
11
+ # a read differs from that only by the reader call.
12
+ class Evilution::Mutator::Operator::AttributeWriteToRead < Evilution::Mutator::Base
13
+ def call(subject, **)
14
+ @deletable_statements = Set.new
15
+ super
16
+ end
17
+
18
+ def visit_statements_node(node)
19
+ @deletable_statements.merge(node.body) if node.body.length > 1
20
+ super
21
+ end
22
+
23
+ def visit_call_node(node)
24
+ mutate_to_read(node) if node.attribute_write? && !@deletable_statements.include?(node)
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def mutate_to_read(node)
31
+ read_length = node.message_loc.end_offset - node.start_offset
32
+ replace_span(node: node, target: node, replacement: byteslice_source(node.start_offset, read_length))
33
+ end
34
+ end
@@ -3,10 +3,18 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::BangMethod < Evilution::Mutator::Base
6
+ # Methods that have an in-place bang twin on at least one of String, Array,
7
+ # Hash or Set (the same set on every supported Ruby, 3.3+), plus `update` /
8
+ # `save` for ActiveRecord. String's `succ` / `next` are left out: those
9
+ # names are far more often called on Integer, which has no bang twin.
6
10
  KNOWN_BANG_PAIRS = %i[
7
- sort map collect select reject uniq compact flatten
8
- shuffle reverse slice gsub sub strip chomp chop squeeze
9
- delete encode merge update save
11
+ sort sort_by map collect select filter reject uniq compact flatten
12
+ shuffle reverse rotate slice
13
+ gsub sub tr tr_s strip lstrip rstrip chomp chop squeeze delete
14
+ delete_prefix delete_suffix capitalize downcase upcase swapcase
15
+ scrub encode unicode_normalize
16
+ merge transform_keys transform_values
17
+ update save
10
18
  ].to_set.freeze
11
19
 
12
20
  def visit_call_node(node)
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace an arithmetic or bitwise expression with one of its operands:
6
+ # `a + b` becomes `a` and `b`.
7
+ #
8
+ # A survivor means one operand never affects the tested result. Comparisons
9
+ # are left to the comparison and conditional operators.
10
+ #
11
+ # An operand is not promoted when the other side is the operator's integer
12
+ # identity — `a + 0` or `a * 1` already equals `a`. A float literal does not
13
+ # count (`a + 0.0` turns an Integer into a Float), and neither do shifts,
14
+ # since `<<` also appends. A binary send in void statement position is left
15
+ # to statement_deletion.
16
+ class Evilution::Mutator::Operator::BinaryOperandPromotion < Evilution::Mutator::Base
17
+ OPERATORS = %i[+ - * / % ** & | ^ << >>].to_set.freeze
18
+ private_constant :OPERATORS
19
+
20
+ # The literal on the right that makes `left <op> literal` equal `left`.
21
+ RIGHT_IDENTITIES = { :+ => 0, :- => 0, :* => 1, :/ => 1, :** => 1, :| => 0, :^ => 0 }.freeze
22
+ private_constant :RIGHT_IDENTITIES
23
+
24
+ # The literal on the left that makes `literal <op> right` equal `right`.
25
+ LEFT_IDENTITIES = { :+ => 0, :* => 1, :| => 0, :^ => 0 }.freeze
26
+ private_constant :LEFT_IDENTITIES
27
+
28
+ def call(subject, **)
29
+ @void_statements = Set.new
30
+ super
31
+ end
32
+
33
+ def visit_statements_node(node)
34
+ @void_statements.merge(node.body[...-1])
35
+ super
36
+ end
37
+
38
+ def visit_call_node(node)
39
+ right = right_operand(node)
40
+ promote_operands(node, node.receiver, right) if right && !@void_statements.include?(node)
41
+ super
42
+ end
43
+
44
+ private
45
+
46
+ # The single argument of a binary operator send; nil otherwise. An operator
47
+ # send always has a receiver — `+b` without one is the unary `+@`.
48
+ def right_operand(node)
49
+ return unless OPERATORS.include?(node.name)
50
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [right]]
51
+
52
+ right
53
+ end
54
+
55
+ def promote_operands(node, left, right)
56
+ promote_child(node, left) unless integer_literal?(right, RIGHT_IDENTITIES[node.name])
57
+ promote_child(node, right) unless integer_literal?(left, LEFT_IDENTITIES[node.name])
58
+ end
59
+
60
+ def integer_literal?(operand, value)
61
+ operand.is_a?(Prism::IntegerNode) && operand.value == value
62
+ end
63
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a call with its parameter-less block's body, run once in place:
6
+ # `Base.transaction { account.save! }` becomes `account.save!`, and
7
+ # `3.times { retry_call }` becomes `retry_call`. A multi-statement body is
8
+ # grouped in parentheses so it still evaluates to its last statement where
9
+ # the call's value is used.
10
+ #
11
+ # A survivor means the wrapping call is never observed: a transaction whose
12
+ # rollback no test triggers, a lock nothing contends for, an iteration count
13
+ # nothing checks.
14
+ #
15
+ # Blocks with parameters (including `_1` / `it` and block-locals) are
16
+ # skipped — the unwrapped body would reference a variable that no longer
17
+ # exists; explicitly empty pipes `{ || ... }` count as parameter-less. So are
18
+ # bodies with a rescue / ensure clause, and bodies using `break` / `next` /
19
+ # `redo`, which do not parse outside a block and are dropped by the parse
20
+ # guard.
21
+ class Evilution::Mutator::Operator::BlockBodyPromotion < Evilution::Mutator::Base
22
+ def visit_call_node(node)
23
+ block = node.block
24
+ unwrap(node, block.body) if block.is_a?(Prism::BlockNode) && parameterless?(block.parameters)
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ # No parameters at all, or explicitly empty pipes `{ || ... }`. Block-local
31
+ # variables (`{ |;tmp| ... }`) count as parameters: unwrapping would turn
32
+ # them into method locals.
33
+ def parameterless?(parameters)
34
+ parameters.nil? ||
35
+ (parameters.is_a?(Prism::BlockParametersNode) && parameters.parameters.nil? && parameters.locals.empty?)
36
+ end
37
+
38
+ def unwrap(node, body)
39
+ return unless body.is_a?(Prism::StatementsNode)
40
+
41
+ statements = body.body
42
+ replacement = statements.length == 1 ? source_of(statements.first) : "(#{source_of(body)})"
43
+ replace_span(node: node, target: node, replacement: replacement)
44
+ end
45
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a block's body with `nil`: `items.map { |item| item * 2 }` becomes
6
+ # `items.map { |item| nil }`. Unlike block_removal, the iteration itself
7
+ # stays, so a survivor means the block runs but nothing observes what it
8
+ # does.
9
+ #
10
+ # A `do ... rescue ... end` block keeps its rescue / ensure clauses; only
11
+ # the main statements become nil.
12
+ #
13
+ # Blocks that are themselves the loop are skipped — `loop { }` and an
14
+ # endless `cycle { }` or `cycle(nil) { }` — since with a nil body nothing
15
+ # breaks out and the mutant would hang until the per-mutation timeout (see
16
+ # EV-170m.7).
17
+ class Evilution::Mutator::Operator::BlockBodyToNil < Evilution::Mutator::Base
18
+ def visit_call_node(node)
19
+ block = node.block
20
+ empty_body(node, block) if block.is_a?(Prism::BlockNode) && !endless_iteration?(node)
21
+ super
22
+ end
23
+
24
+ private
25
+
26
+ def empty_body(node, block)
27
+ body = block.body
28
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
29
+ mutate_to_nil(node, target: statements)
30
+ end
31
+
32
+ def endless_iteration?(node)
33
+ case node.name
34
+ when :loop then kernel_receiver?(node.receiver)
35
+ when :cycle then endless_count?(node.arguments)
36
+ end
37
+ end
38
+
39
+ # `loop`, `Kernel.loop` or `::Kernel.loop` — not a namespaced `Acme::Kernel`.
40
+ def kernel_receiver?(receiver)
41
+ case receiver
42
+ when nil then true
43
+ when Prism::ConstantReadNode then receiver.name == :Kernel
44
+ when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Kernel
45
+ end
46
+ end
47
+
48
+ # `cycle` and `cycle(nil)` both repeat forever.
49
+ def endless_count?(arguments)
50
+ arguments.nil? || (arguments in Prism::ArgumentsNode[arguments: [Prism::NilNode]])
51
+ end
52
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a block's body with a bare `raise`: `items.map { |item| item * 2 }`
6
+ # becomes `items.map { |item| raise }`.
7
+ #
8
+ # A survivor means no test ever invokes the block — every collection it
9
+ # iterates is empty, or the callback is never called. The raise ends the
10
+ # block on its first call, so unlike block_body_to_nil a `loop { }` is safe
11
+ # here.
12
+ #
13
+ # A block whose body has a `rescue` clause is skipped: that clause would
14
+ # catch the raise, and the mutant would exercise the rescue path instead of
15
+ # the invocation. An `ensure` clause does not swallow it and stays.
16
+ class Evilution::Mutator::Operator::BlockBodyToRaise < Evilution::Mutator::Base
17
+ def visit_call_node(node)
18
+ block = node.block
19
+ raise_in_body(node, block.body) if block.is_a?(Prism::BlockNode)
20
+ super
21
+ end
22
+
23
+ private
24
+
25
+ def raise_in_body(node, body)
26
+ if body.is_a?(Prism::BeginNode)
27
+ return if body.rescue_clause
28
+
29
+ body = body.statements
30
+ end
31
+
32
+ replace_span(node: node, target: body, replacement: "raise")
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a method call with `nil`: `user.name` becomes `nil`, and the same
6
+ # for implicit-self calls, operator calls and calls carrying a block.
7
+ #
8
+ # The broadest value-level probe on a call site. The survivor it targets is a
9
+ # call whose result is never asserted — the test runs the code but checks
10
+ # nothing that depends on what the call returned.
11
+ #
12
+ # Three positions are skipped as noise. A call in void statement position
13
+ # (every statement of a body except the last) has no value to replace;
14
+ # deleting it is statement_deletion's job. A call used as the receiver of
15
+ # another call turns into `nil.foo`, a NoMethodError any test that reaches it
16
+ # kills; the outer call is nil-ified instead. An attribute or index write
17
+ # (`a.b = c`, `a[i] = c`) is a side effect, not a value.
18
+ class Evilution::Mutator::Operator::CallToNil < Evilution::Mutator::Base
19
+ def call(subject, **)
20
+ @skipped_calls = Set.new
21
+ super
22
+ end
23
+
24
+ def visit_statements_node(node)
25
+ @skipped_calls.merge(node.body[...-1])
26
+ super
27
+ end
28
+
29
+ def visit_call_node(node)
30
+ @skipped_calls.add(node.receiver)
31
+ mutate_to_nil(node) unless @skipped_calls.include?(node) || node.attribute_write?
32
+ super
33
+ end
34
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a conversion with an empty value of its target type: `value.to_a`
6
+ # becomes `[]`, `value.to_h` becomes `{}`, `value.to_s` becomes `""`, and the
7
+ # same for the implicit `to_ary` / `to_hash` / `to_str` forms.
8
+ #
9
+ # The type stays right, so a survivor means tests check what kind of value
10
+ # comes out but never what it contains.
11
+ #
12
+ # An empty hash that is the first argument of an unparenthesized call,
13
+ # `yield` or `super` is written `({})`: bare `{}` there would be parsed as a
14
+ # block. A nil receiver is skipped — its conversion is already empty.
15
+ class Evilution::Mutator::Operator::CoercionEmptying < Evilution::Mutator::Base
16
+ EMPTY_VALUES = {
17
+ to_a: "[]", to_ary: "[]",
18
+ to_h: "{}", to_hash: "{}",
19
+ to_s: '""', to_str: '""'
20
+ }.freeze
21
+ private_constant :EMPTY_VALUES
22
+
23
+ def call(subject, **)
24
+ @bare_first_arguments = Set.new
25
+ super
26
+ end
27
+
28
+ def visit_call_node(node)
29
+ note_bare_first_argument(node, node.opening_loc)
30
+ empty(node) if emptiable?(node)
31
+ super
32
+ end
33
+
34
+ def visit_yield_node(node)
35
+ note_bare_first_argument(node, node.lparen_loc)
36
+ super
37
+ end
38
+
39
+ def visit_super_node(node)
40
+ note_bare_first_argument(node, node.lparen_loc)
41
+ super
42
+ end
43
+
44
+ private
45
+
46
+ def note_bare_first_argument(node, opening)
47
+ @bare_first_arguments.add(node.arguments.arguments.first) if opening.nil? && node.arguments
48
+ end
49
+
50
+ def emptiable?(node)
51
+ EMPTY_VALUES.key?(node.name) && node.receiver && !node.receiver.is_a?(Prism::NilNode) &&
52
+ node.arguments.nil? && node.block.nil?
53
+ end
54
+
55
+ def empty(node)
56
+ value = EMPTY_VALUES.fetch(node.name)
57
+ value = "(#{value})" if value == "{}" && @bare_first_arguments.include?(node)
58
+ replace_span(node: node, target: node, replacement: value)
59
+ end
60
+ end
@@ -12,10 +12,10 @@ class Evilution::Mutator::Operator::CollectionReplacement < Evilution::Mutator::
12
12
  collect: [:each],
13
13
  sort: [:sort_by],
14
14
  sort_by: [:sort],
15
- find: [:detect],
16
- detect: [:find],
17
- any?: [:all?],
18
- all?: [:any?],
15
+ find: %i[detect first last],
16
+ detect: %i[find first last],
17
+ any?: %i[all? empty? none?],
18
+ all?: %i[any? none?],
19
19
  count: [:length],
20
20
  length: [:count],
21
21
  pop: [:shift],
@@ -30,10 +30,10 @@ class Evilution::Mutator::Operator::CollectionReplacement < Evilution::Mutator::
30
30
  grep_v: [:grep],
31
31
  take: [:drop],
32
32
  drop: [:take],
33
- min: [:max],
34
- max: [:min],
35
- min_by: [:max_by],
36
- max_by: [:min_by],
33
+ min: %i[max first last],
34
+ max: %i[min first last],
35
+ min_by: %i[max_by first last],
36
+ max_by: %i[min_by first last],
37
37
  compact: [:flatten],
38
38
  flatten: [:compact],
39
39
  zip: [:product],
@@ -41,7 +41,17 @@ class Evilution::Mutator::Operator::CollectionReplacement < Evilution::Mutator::
41
41
  first: [:last],
42
42
  last: [:first],
43
43
  keys: [:values],
44
- values: [:keys]
44
+ values: [:keys],
45
+ sample: %i[first last],
46
+ fetch: [:key?],
47
+ at: %i[fetch key?],
48
+ delete_if: [:reject],
49
+ keep_if: [:select],
50
+ filter_map: [:map],
51
+ chunk: [:each],
52
+ chunk_while: [:each],
53
+ each_with_index: [:each],
54
+ slice_when: [:each]
45
55
  }.freeze
46
56
 
47
57
  def visit_call_node(node)
@@ -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
@@ -12,22 +12,47 @@ class Evilution::Mutator::Operator::ComparisonReplacement < Evilution::Mutator::
12
12
  :!= => [:==]
13
13
  }.freeze
14
14
 
15
+ # Operators rewritten into a method call, since the method cannot be
16
+ # swapped in as an operator (`a eql? b` does not parse). Ordering
17
+ # comparisons collapse to the stricter equalities — `a < b` becomes
18
+ # `a.eql?(b)` and `a.equal?(b)` — and `a =~ b` becomes `a.match?(b)`,
19
+ # which returns a boolean and leaves `$~` / `$1` unset.
20
+ EQUALITY_METHODS = %i[eql? equal?].freeze
21
+ private_constant :EQUALITY_METHODS
22
+
23
+ METHOD_REPLACEMENTS = {
24
+ :< => EQUALITY_METHODS,
25
+ :<= => EQUALITY_METHODS,
26
+ :> => EQUALITY_METHODS,
27
+ :>= => EQUALITY_METHODS,
28
+ :=~ => [:match?]
29
+ }.freeze
30
+ private_constant :METHOD_REPLACEMENTS
31
+
15
32
  def visit_call_node(node)
16
- replacements = REPLACEMENTS[node.name]
17
- return super unless replacements
33
+ replace_operator(node)
34
+ replace_with_methods(node)
35
+ super
36
+ end
37
+
38
+ private
18
39
 
40
+ def replace_operator(node)
19
41
  loc = node.message_loc
20
- return super unless loc
21
-
22
- replacements.each do |replacement|
23
- add_mutation(
24
- offset: loc.start_offset,
25
- length: loc.length,
26
- replacement: replacement.to_s,
27
- node: node
28
- )
42
+
43
+ REPLACEMENTS.fetch(node.name, []).each do |replacement|
44
+ add_mutation(offset: loc.start_offset, length: loc.length, replacement: replacement.to_s, node: node)
29
45
  end
46
+ end
30
47
 
31
- super
48
+ def replace_with_methods(node)
49
+ methods = METHOD_REPLACEMENTS[node.name]
50
+ return unless methods && (node.arguments in Prism::ArgumentsNode[arguments: [argument]])
51
+
52
+ left = receiver_source(node.receiver)
53
+ methods.each do |method|
54
+ add_mutation(offset: node.start_offset, length: node.location.length,
55
+ replacement: "#{left}.#{method}(#{source_of(argument)})", node: node)
56
+ end
32
57
  end
33
58
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Resolve a dynamic constant lookup with a literal name into a constant path:
6
+ # `Registry.const_get(:Handler)` becomes `Registry::Handler`. A literal
7
+ # inherit flag is dropped; a receiverless call resolves against `self`.
8
+ #
9
+ # Both forms find the same own, inherited and mixed-in constants and raise the
10
+ # same NameError for a missing one. They differ on top-level constants (found
11
+ # by `const_get` through Object, rejected by `::`), on private constants
12
+ # (returned by `const_get` only) and with `inherit = false` (ancestors skipped
13
+ # by `const_get`, searched by `::`). A survivor means none of those paths is
14
+ # exercised, so the dynamic lookup may not be needed.
15
+ #
16
+ # Safe navigation is skipped: a constant path has no nil-safe form.
17
+ class Evilution::Mutator::Operator::ConstGetToConstantPath < Evilution::Mutator::Base
18
+ CONSTANT_NAME = /\A[A-Z]\w*\z/
19
+ private_constant :CONSTANT_NAME
20
+
21
+ # `nil` behaves like `false`.
22
+ INHERIT_FLAGS = [Prism::TrueNode, Prism::FalseNode, Prism::NilNode].freeze
23
+ private_constant :INHERIT_FLAGS
24
+
25
+ def visit_call_node(node)
26
+ rewrite(node) if node.name == :const_get && node.block.nil? && !node.safe_navigation?
27
+ super
28
+ end
29
+
30
+ private
31
+
32
+ def rewrite(node)
33
+ name = constant_name(node)
34
+ return if name.nil?
35
+
36
+ receiver = node.receiver ? source_of(node.receiver) : "self"
37
+ add_mutation(offset: node.start_offset, length: node.location.length, replacement: "#{receiver}::#{name}", node: node)
38
+ end
39
+
40
+ def constant_name(node)
41
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [Prism::SymbolNode => symbol, *flags]]
42
+ return unless flags.empty? || literal_inherit_flag?(flags)
43
+
44
+ symbol.unescaped if symbol.unescaped.match?(CONSTANT_NAME)
45
+ end
46
+
47
+ def literal_inherit_flag?(flags)
48
+ flags.length == 1 && INHERIT_FLAGS.any? { |type| flags.first.is_a?(type) }
49
+ end
50
+ 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 = node.arguments ? node.arguments.arguments : []
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