evilution 1.1.0 → 1.3.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +58 -0
  3. data/.rubocop_todo.yml +9 -0
  4. data/CHANGELOG.md +86 -0
  5. data/README.md +202 -14
  6. data/docs/architecture.md +54 -5
  7. data/docs/isolation.md +3 -4
  8. data/exe/evil +8 -1
  9. data/exe/evilution +8 -1
  10. data/lib/evilution/ast/local_reads.rb +43 -0
  11. data/lib/evilution/ast/parser.rb +1 -0
  12. data/lib/evilution/ast/pattern/filter.rb +1 -0
  13. data/lib/evilution/ast/pattern/parser.rb +1 -0
  14. data/lib/evilution/ast/pattern.rb +2 -0
  15. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  16. data/lib/evilution/baseline.rb +37 -15
  17. data/lib/evilution/child_output.rb +1 -1
  18. data/lib/evilution/cli/command.rb +1 -0
  19. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  20. data/lib/evilution/cli/commands.rb +2 -0
  21. data/lib/evilution/cli/dispatcher.rb +2 -0
  22. data/lib/evilution/cli/exit_guard.rb +61 -0
  23. data/lib/evilution/cli/parsed_args.rb +2 -0
  24. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  25. data/lib/evilution/cli/parser/file_args.rb +2 -0
  26. data/lib/evilution/cli/parser/options_builder.rb +4 -0
  27. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  28. data/lib/evilution/cli/parser.rb +1 -0
  29. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  30. data/lib/evilution/cli/printers.rb +2 -0
  31. data/lib/evilution/cli/result.rb +2 -0
  32. data/lib/evilution/cli.rb +6 -0
  33. data/lib/evilution/config/builders.rb +2 -0
  34. data/lib/evilution/config/env_loader.rb +2 -0
  35. data/lib/evilution/config/file_loader.rb +1 -0
  36. data/lib/evilution/config/sources.rb +1 -0
  37. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  38. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  39. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  40. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  41. data/lib/evilution/config/validators/hooks.rb +1 -0
  42. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  43. data/lib/evilution/config/validators/integration.rb +1 -0
  44. data/lib/evilution/config/validators/isolation.rb +1 -0
  45. data/lib/evilution/config/validators/jobs.rb +1 -0
  46. data/lib/evilution/config/validators/preload.rb +1 -0
  47. data/lib/evilution/config/validators/profile.rb +1 -0
  48. data/lib/evilution/config/validators/spec_mappings.rb +1 -0
  49. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  50. data/lib/evilution/config/validators/warmup.rb +17 -0
  51. data/lib/evilution/config/validators.rb +2 -0
  52. data/lib/evilution/config.rb +7 -5
  53. data/lib/evilution/coverage.rb +1 -1
  54. data/lib/evilution/coverage_example_filter.rb +1 -1
  55. data/lib/evilution/diagnostic.rb +22 -0
  56. data/lib/evilution/example_filter.rb +1 -1
  57. data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
  58. data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
  59. data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
  60. data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
  61. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  62. data/lib/evilution/integration/minitest.rb +2 -1
  63. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
  64. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  65. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +5 -1
  66. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  67. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  68. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  69. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  70. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  71. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  72. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  73. data/lib/evilution/integration/rspec/state_guard.rb +6 -0
  74. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
  75. data/lib/evilution/integration/rspec.rb +48 -2
  76. data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
  77. data/lib/evilution/isolation/fork.rb +29 -8
  78. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  79. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  80. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  81. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  82. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  83. data/lib/evilution/mutation.rb +1 -1
  84. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  85. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  86. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  87. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  88. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  89. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  90. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  91. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  92. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  93. data/lib/evilution/mutator/operator/block_destructuring_expansion.rb +85 -0
  94. data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -0
  95. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  96. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  97. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  98. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  99. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  100. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  101. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  102. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  103. data/lib/evilution/mutator/operator/forwarding_super_to_explicit.rb +71 -0
  104. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  105. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  106. data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
  107. data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
  108. data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
  109. data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
  110. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -0
  111. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  112. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  113. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  114. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  115. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  116. data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
  117. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  118. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  119. data/lib/evilution/mutator/operator/typed_default_return.rb +84 -0
  120. data/lib/evilution/mutator/primitives.rb +25 -0
  121. data/lib/evilution/mutator/registry.rb +32 -1
  122. data/lib/evilution/parallel/pool.rb +1 -0
  123. data/lib/evilution/parallel_db_warning.rb +1 -1
  124. data/lib/evilution/process_supervisor.rb +20 -8
  125. data/lib/evilution/rails_warmup.rb +61 -0
  126. data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
  127. data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
  128. data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
  129. data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
  130. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
  131. data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
  132. data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
  133. data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
  134. data/lib/evilution/reporter/cli/trailer.rb +11 -7
  135. data/lib/evilution/reporter/cli.rb +20 -4
  136. data/lib/evilution/reporter/html/report.rb +1 -0
  137. data/lib/evilution/reporter/json/subjects.rb +29 -0
  138. data/lib/evilution/reporter/json.rb +23 -1
  139. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  140. data/lib/evilution/result/mutation_result.rb +3 -2
  141. data/lib/evilution/result/neutral_reason.rb +35 -0
  142. data/lib/evilution/result/subject_score.rb +28 -0
  143. data/lib/evilution/result/subject_scorer.rb +38 -0
  144. data/lib/evilution/result/summary.rb +44 -2
  145. data/lib/evilution/runner/baseline_runner.rb +17 -7
  146. data/lib/evilution/runner/canary.rb +52 -5
  147. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  148. data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
  149. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +23 -10
  150. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
  151. data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
  152. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
  153. data/lib/evilution/runner/mutation_executor.rb +32 -4
  154. data/lib/evilution/runner/report_publisher.rb +31 -9
  155. data/lib/evilution/runner/target_spec_audit.rb +43 -0
  156. data/lib/evilution/runner.rb +10 -1
  157. data/lib/evilution/source_ast_cache.rb +1 -1
  158. data/lib/evilution/spec_ast_cache.rb +1 -1
  159. data/lib/evilution/version.rb +1 -1
  160. data/lib/evilution.rb +35 -0
  161. metadata +50 -2
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a call with its only argument: `normalize(value)` becomes `value`.
6
+ #
7
+ # A survivor means the method's transformation of its input is never
8
+ # observed — the tests would pass if the value went through untouched.
9
+ # Distinct from method_call_removal, which keeps the receiver instead.
10
+ #
11
+ # Only identifier-named methods with a single positional argument qualify.
12
+ # Operator methods (`a + b`, `a[i]`) are left to binary operand promotion,
13
+ # attribute writes to attribute_write_to_read, and a call in void statement
14
+ # position to statement_deletion, since there the promoted argument's value
15
+ # is discarded anyway.
16
+ class Evilution::Mutator::Operator::ArgumentPropagation < Evilution::Mutator::Base
17
+ NON_POSITIONAL = [
18
+ Prism::SplatNode,
19
+ Prism::KeywordHashNode,
20
+ Prism::BlockArgumentNode,
21
+ Prism::ForwardingArgumentsNode
22
+ ].freeze
23
+ private_constant :NON_POSITIONAL
24
+
25
+ IDENTIFIER = /\A[[:alpha:]_]/
26
+ private_constant :IDENTIFIER
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
+ promote_child(node, sole_positional_argument(node)) if propagatable?(node)
40
+ super
41
+ end
42
+
43
+ private
44
+
45
+ def sole_positional_argument(node)
46
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
47
+
48
+ argument unless NON_POSITIONAL.any? { |type| argument.is_a?(type) }
49
+ end
50
+
51
+ def propagatable?(node)
52
+ node.name.match?(IDENTIFIER) && !node.attribute_write? && !@void_statements.include?(node)
53
+ end
54
+ end
@@ -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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Flatten a destructuring group in a block's parameter list:
8
+ # `pairs.each_with_index { |(key, value), index| ... }` becomes
9
+ # `pairs.each_with_index { |key, value, index| ... }`.
10
+ #
11
+ # The two forms bind differently once the group has a sibling: given `[1, 2]`
12
+ # and `3`, the grouped form binds key=1, value=2, index=3, while the flat form
13
+ # binds key=[1, 2], value=3, index=nil. A survivor means nothing asserts the
14
+ # shape of what the yielder hands over.
15
+ #
16
+ # A lone group is left alone. With one parameter and a single yielded array —
17
+ # what `each` over pairs, and `Hash#each`, actually yield — both forms bind the
18
+ # same values, so the mutation would be behaviour-preserving and survive every
19
+ # suite. The forms do diverge there for a yielder that passes several values,
20
+ # but which one a block is handed cannot be known from the source, and a
21
+ # guaranteed survivor on the common idiom is the worse trade.
22
+ #
23
+ # Methods and lambdas are left alone as well: both bind by arity, so flattening
24
+ # a group in their signatures raises ArgumentError at every call site instead of
25
+ # changing how arguments are distributed.
26
+ class Evilution::Mutator::Operator::BlockDestructuringExpansion < Evilution::Mutator::Base
27
+ def visit_block_node(node)
28
+ expand_groups(node)
29
+ super
30
+ end
31
+
32
+ private
33
+
34
+ def expand_groups(node)
35
+ parameters = node.parameters
36
+ return unless parameters.is_a?(Prism::BlockParametersNode)
37
+
38
+ inner = parameters.parameters
39
+ return unless inner.is_a?(Prism::ParametersNode)
40
+ return unless siblings?(inner)
41
+
42
+ groups(inner).each { |group| flatten_group(group) }
43
+ end
44
+
45
+ # Both lists can hold a destructuring group: `|(a, b), c|` puts it in
46
+ # requireds, `|*rest, (a, b)|` in posts.
47
+ def groups(parameters)
48
+ [*parameters.requireds, *parameters.posts].grep(Prism::MultiTargetNode)
49
+ end
50
+
51
+ def siblings?(parameters)
52
+ parameter_count(parameters) > 1
53
+ end
54
+
55
+ # Block-local variables (`|a; tmp|`) are not parameters and so are not
56
+ # siblings — they change nothing about how arguments bind.
57
+ def parameter_count(parameters)
58
+ [
59
+ *parameters.requireds,
60
+ *parameters.optionals,
61
+ parameters.rest,
62
+ *parameters.posts,
63
+ *parameters.keywords,
64
+ parameters.keyword_rest,
65
+ parameters.block
66
+ ].compact.length
67
+ end
68
+
69
+ # Only the group's own parentheses go; anything nested inside keeps its own.
70
+ # A group written in a block's parameter list always carries them — the
71
+ # parenthesis-free MultiTargetNode belongs to multiple assignment, which is
72
+ # not reachable from here.
73
+ def flatten_group(group)
74
+ lparen = group.lparen_loc
75
+ rparen = group.rparen_loc
76
+
77
+ inner_start = lparen.start_offset + lparen.length
78
+ add_mutation(
79
+ offset: group.location.start_offset,
80
+ length: group.location.length,
81
+ replacement: byteslice_source(inner_start, rparen.start_offset - inner_start),
82
+ node: group
83
+ )
84
+ end
85
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Drop the single parameter of a block: `users.each { |u| touch(u) }` becomes
8
+ # `users.each { touch(u) }`.
9
+ #
10
+ # A block tolerates being handed more arguments than it declares, so the mutant
11
+ # runs until the body reads the dropped name, where it raises NameError. A
12
+ # survivor therefore means the block never ran on a path the suite asserts —
13
+ # the collection was empty, or the call was stubbed away.
14
+ #
15
+ # Not to be confused with BlockParamRemoval, which removes a method's `&block`
16
+ # parameter from its signature.
17
+ #
18
+ # Two shapes are deliberately left alone. A parameter the body never reads makes
19
+ # the mutation behaviour-preserving, so it would survive every suite and report
20
+ # a coverage gap that is not there. A lambda checks its arity, so dropping its
21
+ # parameter raises ArgumentError on every call, whatever the body does.
22
+ class Evilution::Mutator::Operator::BlockParameterDrop < Evilution::Mutator::Base
23
+ def visit_block_node(node)
24
+ drop_parameter(node)
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def drop_parameter(node)
31
+ return unless droppable?(node)
32
+
33
+ location = node.parameters.location
34
+ leading = leading_space(location)
35
+
36
+ add_mutation(
37
+ offset: location.start_offset - leading,
38
+ length: location.length + leading,
39
+ replacement: "",
40
+ node: node
41
+ )
42
+ end
43
+
44
+ def droppable?(node)
45
+ parameters = node.parameters
46
+ return false unless parameters.is_a?(Prism::BlockParametersNode)
47
+ return false unless parameters.locals.empty?
48
+
49
+ names = single_parameter_names(parameters.parameters)
50
+ names.any? { |name| body_reads?(node.body, name) }
51
+ end
52
+
53
+ # The space the block keyword leaves in front of the parameters goes with
54
+ # them, so `{ |u| body }` mutates to `{ body }` and `do |u|` to `do` rather
55
+ # than leaving a double space or a trailing one.
56
+ def leading_space(location)
57
+ preceding = @file_source.byteslice(location.start_offset - 1, 1)
58
+ preceding == " " ? 1 : 0
59
+ end
60
+
61
+ # The names bound by a lone required parameter, which is either a plain name
62
+ # or a destructuring target (`|(key, value)|`). Anything else — a second
63
+ # parameter, an optional, a rest, a keyword, a block pass, or the implicit
64
+ # rest Prism reports for the trailing comma in `|key,|` — means this is not a
65
+ # single-parameter block, and numbered parameters carry no node to remove.
66
+ def single_parameter_names(parameters)
67
+ return [] unless parameters.is_a?(Prism::ParametersNode)
68
+ return [] unless parameters.requireds.length == 1
69
+ return [] unless other_parameters_absent?(parameters)
70
+
71
+ parameter_names(parameters.requireds.first)
72
+ end
73
+
74
+ # Post-required parameters are not checked: block syntax only produces them
75
+ # after a rest parameter, which is rejected here anyway.
76
+ def other_parameters_absent?(parameters)
77
+ parameters.optionals.empty? && parameters.keywords.empty? &&
78
+ parameters.rest.nil? && parameters.keyword_rest.nil? && parameters.block.nil?
79
+ end
80
+
81
+ def parameter_names(node)
82
+ case node
83
+ when Prism::RequiredParameterNode then [node.name.to_s]
84
+ when Prism::MultiTargetNode then node.lefts.flat_map { |child| parameter_names(child) }
85
+ else []
86
+ end
87
+ end
88
+
89
+ # An underscore-prefixed name announces a parameter that is not meant to be
90
+ # read, so it is treated as unused even where the body happens to mention it.
91
+ def body_reads?(body, name)
92
+ return false if name.start_with?("_")
93
+
94
+ Evilution::AST::LocalReads.new.call(body, name)
95
+ end
96
+ 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)