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
@@ -4,39 +4,57 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
6
6
  def visit_splat_node(node)
7
- mutate_remove_splat(node) if node.expression
7
+ mutate_remove_splat(node) if node.expression && !exempt_splats.include?(node)
8
8
 
9
9
  super
10
10
  end
11
11
 
12
+ # Inside a hash literal `**opts` merges a hash in; a bare `opts` is not an
13
+ # element (`{ opts }` is a syntax error), so these splats are skipped.
12
14
  def visit_hash_node(node)
13
- node.elements.each { |el| hash_elements.add(el) }
15
+ exempt_splats.merge(node.elements.grep(Prism::AssocSplatNode))
16
+ super
17
+ end
18
+
19
+ # The rest of a pattern binds what is left over; it is not a splat in a
20
+ # call or a literal, so these splats are skipped. Dropping `**` from
21
+ # `{ key:, **opts }` is a syntax error. Dropping `*` from `[a, *rest]`
22
+ # parses, but it narrows the pattern to a fixed length. That mutant is
23
+ # deliberately not emitted, and the pattern operators keep a binding rest
24
+ # (`*rest`, `**opts`) as written. The rest of a multiple assignment
25
+ # (`a, *b = x`) is still mutated: `a, b = x` parses and changes what `b`
26
+ # binds.
27
+ def visit_hash_pattern_node(node)
28
+ exempt_splats.add(node.rest) if node.rest
29
+ super
30
+ end
31
+
32
+ def visit_array_pattern_node(node)
33
+ exempt_splats.add(node.rest) if node.rest
34
+ super
35
+ end
36
+
37
+ def visit_find_pattern_node(node)
38
+ exempt_splats.add(node.left)
39
+ exempt_splats.add(node.right)
14
40
  super
15
41
  end
16
42
 
17
43
  # KeywordHashNode wraps call-arg kwargs + `**splat`. When an explicit
18
- # `k: v` precedes a `**opts` splat in the same call, demoting `**opts` to
19
- # bare `opts` puts a positional after a keyword and Ruby rejects it
20
- # (`bar(k: v, opts)` is a syntax error). Mark such splats so
21
- # `visit_assoc_splat_node` skips them. Splats that come BEFORE any kwarg
22
- # (`bar(**opts, k: v)`) are still safe — positional-before-keyword is fine.
44
+ # `k: v` or another `**splat` precedes a `**opts` splat in the same call,
45
+ # demoting `**opts` to bare `opts` puts a positional after a keyword
46
+ # argument and Ruby rejects it (`bar(k: v, opts)` and `bar(**a, opts)` are
47
+ # syntax errors), so such splats are skipped.
48
+ # A splat that comes first (`bar(**opts, k: v)`) is still safe —
49
+ # positional-before-keyword is fine.
23
50
  def visit_keyword_hash_node(node)
24
- seen_kwarg = false
25
- node.elements.each do |el|
26
- if el.is_a?(Prism::AssocSplatNode) && seen_kwarg
27
- kwarg_preceded_splats.add(el)
28
- elsif el.is_a?(Prism::AssocNode)
29
- seen_kwarg = true
30
- end
31
- end
32
-
51
+ exempt_splats.merge(node.elements.drop(1).grep(Prism::AssocSplatNode))
33
52
  super
34
53
  end
35
54
 
36
55
  def visit_assoc_splat_node(node)
37
56
  return super if node.value.nil?
38
- return super if hash_elements.include?(node)
39
- return super if kwarg_preceded_splats.include?(node)
57
+ return super if exempt_splats.include?(node)
40
58
 
41
59
  mutate_remove_double_splat(node)
42
60
 
@@ -45,12 +63,11 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
45
63
 
46
64
  private
47
65
 
48
- def hash_elements
49
- @hash_elements ||= Set.new.compare_by_identity
50
- end
51
-
52
- def kwarg_preceded_splats
53
- @kwarg_preceded_splats ||= Set.new.compare_by_identity
66
+ # Splats that are left as written: removing the `*` or `**` would not
67
+ # parse, or would change what a pattern matches. Each visitor that fills
68
+ # the set says why.
69
+ def exempt_splats
70
+ @exempt_splats ||= Set.new.compare_by_identity
54
71
  end
55
72
 
56
73
  def mutate_remove_splat(node)
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap two adjacent commands: `charge(card); send_receipt(user)` becomes
6
+ # `send_receipt(user); charge(card)`.
7
+ #
8
+ # A survivor means no example depends on the order the two side effects
9
+ # happen in. Most pairs of statements could swap without anyone noticing, so
10
+ # only pairs that are likely to matter are touched:
11
+ #
12
+ # - both statements are commands: they call a method, append, write an index,
13
+ # yield or call super. Assigning a local or instance variable stores a
14
+ # value, and statements without such effects swap freely.
15
+ # - neither writes a variable the other reads or writes. Such a pair fails at
16
+ # once when swapped, which tests nothing about order.
17
+ # - neither contains control flow (`return`, `break`, `next`, `redo`, `retry`,
18
+ # `raise`, `fail`), which would skip the other statement.
19
+ # - the second is not the last statement of its body, which gives the body
20
+ # its value.
21
+ # - neither contains a heredoc, whose body sits outside the statement's
22
+ # source range.
23
+ # - they are not writes to different literal keys of the same receiver
24
+ # (`opts[:a] = 1; opts[:b] = 2`), which cannot affect each other.
25
+ class Evilution::Mutator::Operator::StatementReorder < Evilution::Mutator::Base
26
+ CONTROL_FLOW_TYPES = [
27
+ Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode, Prism::RetryNode
28
+ ].freeze
29
+ RAISING_METHODS = %i[raise fail].freeze
30
+
31
+ # Calls that change their receiver without a method name: `a << b`, `a[k] = v`.
32
+ MUTATING_OPERATORS = %i[<< []=].freeze
33
+ METHOD_NAME = /\A[a-zA-Z_]/
34
+
35
+ EFFECT_TYPES = [Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode].freeze
36
+
37
+ # Statements that store a value rather than act.
38
+ VALUE_WRITE_TYPES = [
39
+ Prism::LocalVariableWriteNode, Prism::LocalVariableOperatorWriteNode,
40
+ Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
41
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
42
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode
43
+ ].freeze
44
+
45
+ # Nodes that set a variable, and nodes that read one.
46
+ WRITE_TYPES = [
47
+ *VALUE_WRITE_TYPES, Prism::LocalVariableTargetNode, Prism::InstanceVariableTargetNode,
48
+ Prism::ClassVariableWriteNode, Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode,
49
+ Prism::ClassVariableAndWriteNode, Prism::ClassVariableTargetNode,
50
+ Prism::GlobalVariableWriteNode, Prism::GlobalVariableOperatorWriteNode, Prism::GlobalVariableOrWriteNode,
51
+ Prism::GlobalVariableAndWriteNode, Prism::GlobalVariableTargetNode
52
+ ].freeze
53
+ LITERAL_KEY_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
54
+
55
+ READ_TYPES = [
56
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
57
+ Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
58
+ ].freeze
59
+
60
+ def visit_statements_node(node)
61
+ statements = node.body
62
+ # The last statement stays put, so pairs end one before it.
63
+ statements[0...-1].each_cons(2) do |first, second|
64
+ emit_swap(first, second) if swappable?(first, second)
65
+ end
66
+ super
67
+ end
68
+
69
+ private
70
+
71
+ def swappable?(first, second)
72
+ [first, second].all? { |statement| command?(statement) && !control_flow?(statement) && !heredoc?(statement) } &&
73
+ independent?(first, second) && !distinct_key_writes?(first, second)
74
+ end
75
+
76
+ def command?(statement)
77
+ return false if VALUE_WRITE_TYPES.include?(statement.class)
78
+
79
+ nodes_in(statement).any? { |node| effect?(node) }
80
+ end
81
+
82
+ def effect?(node)
83
+ return true if EFFECT_TYPES.include?(node.class)
84
+ return false unless node.is_a?(Prism::CallNode)
85
+
86
+ node.name.to_s.match?(METHOD_NAME) || MUTATING_OPERATORS.include?(node.name)
87
+ end
88
+
89
+ def control_flow?(statement)
90
+ nodes_in(statement).any? do |node|
91
+ CONTROL_FLOW_TYPES.include?(node.class) ||
92
+ (node.is_a?(Prism::CallNode) && node.receiver.nil? && RAISING_METHODS.include?(node.name))
93
+ end
94
+ end
95
+
96
+ def heredoc?(statement)
97
+ nodes_in(statement).any? do |node|
98
+ node.respond_to?(:heredoc?) && node.heredoc?
99
+ end
100
+ end
101
+
102
+ def distinct_key_writes?(first, second)
103
+ writes = [index_write(first), index_write(second)]
104
+ return false if writes.include?(nil)
105
+ return false unless writes.first.receiver.slice == writes.last.receiver.slice
106
+
107
+ distinct_literal_keys?(writes.map { |write| write.arguments.arguments.first })
108
+ end
109
+
110
+ def distinct_literal_keys?(keys)
111
+ keys.all? { |key| LITERAL_KEY_TYPES.include?(key.class) } && keys.first.slice != keys.last.slice
112
+ end
113
+
114
+ # `h[k] = v` on its own or under a modifier condition (`h[k] = v if v`).
115
+ def index_write(statement)
116
+ if statement.is_a?(Prism::IfNode) || statement.is_a?(Prism::UnlessNode)
117
+ body = statement.statements
118
+ statement = body.body.first if body && body.body.length == 1
119
+ end
120
+ statement if statement.is_a?(Prism::CallNode) && statement.name == :[]=
121
+ end
122
+
123
+ def independent?(first, second)
124
+ first_writes = names(first, WRITE_TYPES)
125
+ second_writes = names(second, WRITE_TYPES)
126
+
127
+ !first_writes.intersect?(names(second, READ_TYPES) | second_writes) &&
128
+ !second_writes.intersect?(names(first, READ_TYPES))
129
+ end
130
+
131
+ def names(statement, types)
132
+ nodes_in(statement).filter_map { |node| node.name if types.include?(node.class) }.to_set
133
+ end
134
+
135
+ def nodes_in(node)
136
+ [node, *node.compact_child_nodes.flat_map { |child| nodes_in(child) }]
137
+ end
138
+
139
+ # Whatever separates the two statements — a line break and indentation, or
140
+ # `; ` — stays between them.
141
+ def emit_swap(first, second)
142
+ start_offset = first.location.start_offset
143
+ end_offset = second.location.end_offset
144
+ separator = byteslice_source(first.location.end_offset, second.location.start_offset - first.location.end_offset)
145
+
146
+ add_mutation(
147
+ offset: start_offset,
148
+ length: end_offset - start_offset,
149
+ replacement: "#{second.slice}#{separator}#{first.slice}",
150
+ node: first
151
+ )
152
+ end
153
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  def initialize(skip_heredoc_literals: false, **rest)
7
9
  super(**rest)
8
10
  @skip_heredoc_literals = skip_heredoc_literals
@@ -32,6 +34,11 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
32
34
  return
33
35
  end
34
36
 
37
+ # The composed value as a whole, next to the chunks and interpolations
38
+ # inside it. A word of `%W[]` has no quotes of its own and is not a
39
+ # literal: `nil` there would be the word "nil".
40
+ emit_string_mutations(node) if node.opening_loc
41
+
35
42
  super
36
43
  end
37
44
 
@@ -59,11 +66,33 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
59
66
  end
60
67
 
61
68
  def visit_interpolated_x_string_node(node)
69
+ return if node.heredoc? && @skip_heredoc_literals
70
+
71
+ mutate_command_to_nil(node)
62
72
  visit_non_string_parts(node)
63
73
  end
64
74
 
75
+ def visit_x_string_node(node)
76
+ mutate_command_to_nil(node)
77
+ super
78
+ end
79
+
65
80
  private
66
81
 
82
+ # A command literal (`` `ls` ``, `%x(ls)`) is only ever replaced as a whole,
83
+ # by `nil`: the command then does not run. Its text is never rewritten, as
84
+ # a changed command would run for real wherever an example runs it.
85
+ def mutate_command_to_nil(node)
86
+ return if node.heredoc?
87
+
88
+ add_mutation(
89
+ offset: node.location.start_offset,
90
+ length: node.location.length,
91
+ replacement: "nil",
92
+ node: node
93
+ )
94
+ end
95
+
67
96
  def visit_non_string_parts(node)
68
97
  node.parts.each do |part|
69
98
  next if part.is_a?(Prism::StringNode)
@@ -3,7 +3,13 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../operator"
6
+ require_relative "../../ast/class_body"
6
7
 
8
+ # Drop the superclass from a class definition: `class Admin < User` becomes
9
+ # `class Admin`.
10
+ #
11
+ # The definition line belongs to no method, so the mutant is attributed to
12
+ # the method anchoring the class body (see Evilution::AST::ClassBody).
7
13
  class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
8
14
  def call(subject, filter: nil)
9
15
  @subject = subject
@@ -11,24 +17,22 @@ class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
11
17
  @mutations = []
12
18
  @filter = filter
13
19
 
14
- enclosing = find_target_class(subject)
15
- return @mutations unless enclosing
16
-
17
- offset, length = superclass_range(enclosing)
18
- add_mutation(offset: offset, length: length, replacement: "", node: enclosing)
20
+ classes_with_superclass_for(subject).each do |class_node|
21
+ offset, length = superclass_range(class_node)
22
+ add_mutation(offset: offset, length: length, replacement: "", node: class_node)
23
+ end
19
24
 
20
25
  @mutations
21
26
  end
22
27
 
23
28
  private
24
29
 
25
- def find_target_class(subject)
30
+ def classes_with_superclass_for(subject)
26
31
  tree = self.class.parsed_tree_for(subject.file_path, @file_source)
27
- enclosing = find_enclosing_class(tree, subject.line_number)
28
- return nil unless enclosing && enclosing.superclass
29
- return nil unless find_first_method_line(enclosing) == subject.line_number
30
32
 
31
- enclosing
33
+ Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).map(&:node).select do |scope|
34
+ scope.is_a?(Prism::ClassNode) && scope.superclass
35
+ end
32
36
  end
33
37
 
34
38
  def superclass_range(class_node)
@@ -39,34 +43,4 @@ class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
39
43
 
40
44
  [name_end, superclass_end - name_end]
41
45
  end
42
-
43
- def find_enclosing_class(tree, target_line)
44
- finder = ClassFinder.new(target_line)
45
- finder.visit(tree)
46
- finder.result
47
- end
48
-
49
- def find_first_method_line(class_node)
50
- return nil unless class_node.body
51
-
52
- class_node.body.body.each do |node|
53
- return node.location.start_line if node.is_a?(Prism::DefNode)
54
- end
55
- nil
56
- end
57
-
58
- # Visitor to find the ClassNode enclosing a given line number.
59
- class ClassFinder < Prism::Visitor
60
- attr_reader :result
61
-
62
- def initialize(target_line)
63
- @target_line = target_line
64
- @result = nil
65
- end
66
-
67
- def visit_class_node(node)
68
- @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
69
- super
70
- end
71
- end
72
46
  end
@@ -1,10 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../operator"
4
+ require_relative "../../ast/round_half_mode"
4
5
 
5
6
  class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
7
+ extend Evilution::Mutator::ConstantValueSubjects
8
+
9
+ def call(subject, filter: nil)
10
+ @round_half_modes = []
11
+ super
12
+ end
13
+
14
+ # The tie-breaking mode of `round` (`half: :even`) is left to
15
+ # RoundHalfModeSwap: a made-up symbol there only raises, and nil rounds
16
+ # like `:up`.
17
+ def visit_call_node(node)
18
+ mode = Evilution::AST::RoundHalfMode.of(node)
19
+ @round_half_modes << mode if mode
20
+ super
21
+ end
22
+
6
23
  def visit_symbol_node(node)
7
- return super if label_form?(node)
24
+ return super if label_form?(node) || round_half_mode?(node)
8
25
 
9
26
  add_mutation(
10
27
  offset: node.location.start_offset,
@@ -23,10 +40,38 @@ class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
23
40
  super
24
41
  end
25
42
 
43
+ # An interpolated symbol (`:"visit_#{type}"`) as a whole. A label key
44
+ # (`"a#{x}": 1`) cannot be replaced by a value, and a word of `%I[]` has no
45
+ # quotes of its own: `nil` there would be the symbol `:nil`.
46
+ def visit_interpolated_symbol_node(node)
47
+ return super if node.opening_loc.nil? || label_form?(node)
48
+
49
+ add_mutation(
50
+ offset: node.location.start_offset,
51
+ length: node.location.length,
52
+ replacement: ':""',
53
+ node: node
54
+ )
55
+
56
+ add_mutation(
57
+ offset: node.location.start_offset,
58
+ length: node.location.length,
59
+ replacement: "nil",
60
+ node: node
61
+ )
62
+
63
+ super
64
+ end
65
+
26
66
  private
27
67
 
68
+ def round_half_mode?(node)
69
+ @round_half_modes.any? { |mode| mode.equal?(node) }
70
+ end
71
+
72
+ # `a:` closes with `:`, a quoted label (`"a b":`) with `":`.
28
73
  def label_form?(node)
29
74
  closing = node.closing_loc
30
- !closing.nil? && closing.slice == ":"
75
+ !closing.nil? && closing.slice.end_with?(":")
31
76
  end
32
77
  end
@@ -29,7 +29,7 @@ class Evilution::Mutator::Operator::ToIToInteger < Evilution::Mutator::Base
29
29
  end
30
30
 
31
31
  def rewrite(node)
32
- arguments = node.arguments ? node.arguments.arguments : []
32
+ arguments = argument_nodes(node)
33
33
  return if arguments.length > 1 || arguments.any?(Prism::SplatNode)
34
34
 
35
35
  parts = [node.receiver, *arguments].map { |part| source_of(part) }
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a variable read with `nil`: a local, an instance, class or global
6
+ # variable, or `self`.
7
+ #
8
+ # A survivor means the value read never reaches an assertion — the tests
9
+ # would pass whatever the variable held. Where the read is passed as a
10
+ # positional argument or interpolated into a string, the mutant is the one
11
+ # argument_nil_substitution or string_interpolation already emits, and the
12
+ # two are counted once.
13
+ #
14
+ # Positions where `nil` says nothing new are skipped:
15
+ #
16
+ # - the receiver of a call, which turns into `nil.name` — a NoMethodError
17
+ # any test that reaches it kills — and likewise the receiver of a write
18
+ # (`counter.total += 1`, `cache[key] ||= value`);
19
+ # - a void statement (every statement of a body except the last), which
20
+ # statement_deletion deletes;
21
+ # - the condition of an `if`, `unless`, `while` or `until`, where `nil` is
22
+ # the `false` conditional_negation already tries;
23
+ # - the left operand of `&&` or `||`, where `nil && b` is the whole
24
+ # expression turned to nil and `nil || b` is `b` promoted;
25
+ # - a block-pass argument, `&nil` being the call without its block;
26
+ # - a double-splatted read, `**nil` raising before Ruby 3.4;
27
+ # - the value a shorthand hash (`name:`) leaves unwritten.
28
+ class Evilution::Mutator::Operator::VariableReadToNil < Evilution::Mutator::Base
29
+ include Evilution::Mutator::ReceiverPositions
30
+
31
+ def call(subject, **)
32
+ @skipped_nodes = Set.new
33
+ super
34
+ end
35
+
36
+ def visit_statements_node(node)
37
+ @skipped_nodes.merge(node.body[...-1])
38
+ super
39
+ end
40
+
41
+ def visit_if_node(node)
42
+ @skipped_nodes.add(node.predicate)
43
+ super
44
+ end
45
+
46
+ def visit_unless_node(node)
47
+ @skipped_nodes.add(node.predicate)
48
+ super
49
+ end
50
+
51
+ def visit_while_node(node)
52
+ @skipped_nodes.add(node.predicate)
53
+ super
54
+ end
55
+
56
+ def visit_until_node(node)
57
+ @skipped_nodes.add(node.predicate)
58
+ super
59
+ end
60
+
61
+ def visit_and_node(node)
62
+ @skipped_nodes.add(node.left)
63
+ super
64
+ end
65
+
66
+ def visit_or_node(node)
67
+ @skipped_nodes.add(node.left)
68
+ super
69
+ end
70
+
71
+ def visit_block_argument_node(node)
72
+ @skipped_nodes.add(node.expression)
73
+ super
74
+ end
75
+
76
+ def visit_assoc_splat_node(node)
77
+ @skipped_nodes.add(node.value)
78
+ super
79
+ end
80
+
81
+ # The read inside has no source of its own: it shares the key's.
82
+ def visit_implicit_node(_node); end
83
+
84
+ def visit_local_variable_read_node(node)
85
+ nilify(node)
86
+ end
87
+
88
+ def visit_instance_variable_read_node(node)
89
+ nilify(node)
90
+ end
91
+
92
+ def visit_class_variable_read_node(node)
93
+ nilify(node)
94
+ end
95
+
96
+ def visit_global_variable_read_node(node)
97
+ nilify(node)
98
+ end
99
+
100
+ def visit_self_node(node)
101
+ nilify(node)
102
+ end
103
+
104
+ private
105
+
106
+ def receiver_found(node)
107
+ @skipped_nodes.add(node)
108
+ end
109
+
110
+ def nilify(node)
111
+ mutate_to_nil(node) unless @skipped_nodes.include?(node)
112
+ end
113
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+ require_relative "../../ast/class_body"
7
+
8
+ # Drop a visibility declaration from a class or module body: `private`,
9
+ # `protected` and `module_function`, bare or naming methods. A definition the
10
+ # declaration wraps is kept: `private def a; end` becomes `def a; end`.
11
+ #
12
+ # A survivor means no example checks what the declaration decides: that a
13
+ # private method cannot be called from outside, or that a module function
14
+ # can.
15
+ #
16
+ # Declarations sit in the class body, outside every method, where no subject
17
+ # reaches them. They are attributed to the method anchoring the enclosing
18
+ # body (see Evilution::AST::ClassBody); a top-level declaration has no body
19
+ # to be attributed to. A call inside a method, or one sent to another
20
+ # receiver, is not a declaration.
21
+ class Evilution::Mutator::Operator::VisibilityRemoval < Evilution::Mutator::Base
22
+ DECLARATIONS = %i[private protected module_function].freeze
23
+
24
+ # What a declaration can wrap and has to leave behind: a method definition,
25
+ # or a call (`private attr_reader :name`, `private helper_names`). The call
26
+ # runs with or without the declaration, so it stays.
27
+ WRAPPED = [Prism::DefNode, Prism::CallNode].freeze
28
+
29
+ def call(subject, filter: nil)
30
+ @subject = subject
31
+ @file_source = File.read(subject.file_path)
32
+ @mutations = []
33
+ @filter = filter
34
+
35
+ declarations_for(subject).each { |declaration| remove(declaration) }
36
+ @mutations
37
+ end
38
+
39
+ private
40
+
41
+ def remove(declaration)
42
+ replacement = replacement_for(argument_nodes(declaration))
43
+ return if replacement.nil?
44
+
45
+ add_mutation(
46
+ offset: declaration.location.start_offset,
47
+ length: declaration.location.length,
48
+ replacement: replacement,
49
+ node: declaration
50
+ )
51
+ end
52
+
53
+ # What is left of the declaration: nothing when it only names methods, the
54
+ # definition when it wraps one. A definition among several arguments
55
+ # (`private attr_reader(:a), :b`) cannot be left behind on its own, so that
56
+ # declaration is not mutated.
57
+ def replacement_for(arguments)
58
+ return "" if arguments.none? { |argument| WRAPPED.include?(argument.class) }
59
+
60
+ arguments.first.slice if arguments.one?
61
+ end
62
+
63
+ def declarations_for(subject)
64
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
65
+
66
+ Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
67
+ body.declarations { |node| declaration?(node) }
68
+ end
69
+ end
70
+
71
+ def declaration?(node)
72
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && DECLARATIONS.include?(node.name)
73
+ end
74
+ end
@@ -3,8 +3,9 @@
3
3
  require_relative "../mutator"
4
4
 
5
5
  # Mutation shapes shared across the operator families: replacing an
6
- # expression with `nil`, and replacing an expression with the source of one
7
- # of its children. Both are built on Base#add_mutation, so the
6
+ # expression with `nil`, replacing an expression with the source of one of
7
+ # its children, and deleting one element of a list. All are built on
8
+ # Base#add_mutation, so the
8
9
  # equivalent-mutant filter and the heredoc-span guards still apply.
9
10
  #
10
11
  # Unlike a bare add_mutation call, these skip rather than emit when the
@@ -30,6 +31,20 @@ module Evilution::Mutator::Primitives
30
31
  replace_span(node: node, target: target, replacement: source_of(child))
31
32
  end
32
33
 
34
+ # Delete the element at `index` from a list written in source order (the
35
+ # elements of an array or hash literal), attributing the mutation to the
36
+ # element. It goes with the separator after it; the last one goes with the
37
+ # separator before it, so a trailing comma stays where it was. The list
38
+ # must hold another element to take the separator from.
39
+ def delete_element(elements, index)
40
+ element = elements[index]
41
+ following = elements[index + 1]
42
+ offset = following ? element.location.start_offset : elements[index - 1].location.end_offset
43
+ stop = following ? following.location.start_offset : element.location.end_offset
44
+
45
+ add_mutation(offset:, length: stop - offset, replacement: "", node: element, skip_unparseable: true)
46
+ end
47
+
33
48
  def replace_span(node:, target:, replacement:)
34
49
  return nil if target.nil?
35
50
 
@@ -45,6 +60,13 @@ module Evilution::Mutator::Primitives
45
60
  )
46
61
  end
47
62
 
63
+ # The arguments of a call, `super`, `yield` or the like, in source order.
64
+ # Prism gives nil, not an empty list, where there are none; this gives the
65
+ # empty list, so a reader can count or iterate without asking first.
66
+ def argument_nodes(node)
67
+ node.arguments ? node.arguments.arguments : []
68
+ end
69
+
48
70
  def source_of(child)
49
71
  location = child.location
50
72
  byteslice_source(location.start_offset, location.length)