evilution 1.2.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +38 -0
  3. data/.rubocop_todo.yml +9 -0
  4. data/CHANGELOG.md +47 -0
  5. data/README.md +65 -7
  6. data/docs/architecture.md +35 -1
  7. data/lib/evilution/ast/parser.rb +1 -0
  8. data/lib/evilution/ast/pattern/filter.rb +1 -0
  9. data/lib/evilution/ast/pattern/parser.rb +1 -0
  10. data/lib/evilution/ast/pattern.rb +2 -0
  11. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  12. data/lib/evilution/baseline.rb +37 -15
  13. data/lib/evilution/child_output.rb +1 -1
  14. data/lib/evilution/cli/command.rb +1 -0
  15. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  16. data/lib/evilution/cli/commands.rb +2 -0
  17. data/lib/evilution/cli/dispatcher.rb +2 -0
  18. data/lib/evilution/cli/exit_guard.rb +2 -0
  19. data/lib/evilution/cli/parsed_args.rb +2 -0
  20. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  21. data/lib/evilution/cli/parser/file_args.rb +2 -0
  22. data/lib/evilution/cli/parser/options_builder.rb +3 -0
  23. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  24. data/lib/evilution/cli/parser.rb +1 -0
  25. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  26. data/lib/evilution/cli/printers.rb +2 -0
  27. data/lib/evilution/cli/result.rb +2 -0
  28. data/lib/evilution/cli.rb +5 -0
  29. data/lib/evilution/config/builders.rb +2 -0
  30. data/lib/evilution/config/env_loader.rb +2 -0
  31. data/lib/evilution/config/file_loader.rb +1 -0
  32. data/lib/evilution/config/sources.rb +1 -0
  33. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  34. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  35. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  36. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  37. data/lib/evilution/config/validators/hooks.rb +1 -0
  38. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  39. data/lib/evilution/config/validators/integration.rb +1 -0
  40. data/lib/evilution/config/validators/isolation.rb +1 -0
  41. data/lib/evilution/config/validators/jobs.rb +1 -0
  42. data/lib/evilution/config/validators/preload.rb +1 -0
  43. data/lib/evilution/config/validators/profile.rb +1 -0
  44. data/lib/evilution/config/validators/spec_mappings.rb +1 -0
  45. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  46. data/lib/evilution/config/validators/warmup.rb +17 -0
  47. data/lib/evilution/config/validators.rb +2 -0
  48. data/lib/evilution/config.rb +4 -3
  49. data/lib/evilution/coverage.rb +1 -1
  50. data/lib/evilution/coverage_example_filter.rb +1 -1
  51. data/lib/evilution/diagnostic.rb +1 -1
  52. data/lib/evilution/example_filter.rb +1 -1
  53. data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
  54. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  55. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  56. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  57. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  58. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  59. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  60. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  61. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  62. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  63. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  64. data/lib/evilution/integration/rspec/state_guard.rb +6 -0
  65. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  66. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  67. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  68. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  69. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  70. data/lib/evilution/mutation.rb +1 -1
  71. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  72. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  73. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  74. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  75. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  76. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  77. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  78. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  79. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  80. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  81. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  82. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  83. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  84. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  85. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  86. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  87. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  88. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  89. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  90. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  91. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  92. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  93. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  94. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  95. data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
  96. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  97. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  98. data/lib/evilution/mutator/primitives.rb +25 -0
  99. data/lib/evilution/mutator/registry.rb +24 -1
  100. data/lib/evilution/parallel/pool.rb +1 -0
  101. data/lib/evilution/parallel_db_warning.rb +1 -1
  102. data/lib/evilution/rails_warmup.rb +61 -0
  103. data/lib/evilution/reporter/html/report.rb +1 -0
  104. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  105. data/lib/evilution/result/subject_scorer.rb +1 -0
  106. data/lib/evilution/runner/baseline_runner.rb +17 -7
  107. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  108. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +17 -10
  109. data/lib/evilution/source_ast_cache.rb +1 -1
  110. data/lib/evilution/spec_ast_cache.rb +1 -1
  111. data/lib/evilution/version.rb +1 -1
  112. data/lib/evilution.rb +23 -0
  113. metadata +27 -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,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)
@@ -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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Make the first step of a `dig` strict: `config.dig(:db, :host)` becomes
6
+ # `config.fetch(:db).dig(:host)`. The reverse of index_to_dig.
7
+ #
8
+ # `dig` returns nil when the first key is missing or holds nil; the fetch
9
+ # chain raises there instead (KeyError / IndexError, or NoMethodError on
10
+ # `nil.dig`). A survivor means no test reaches the lookup with that key
11
+ # absent.
12
+ #
13
+ # Only calls with two or more positional keys qualify; the remaining keys
14
+ # keep their original formatting.
15
+ class Evilution::Mutator::Operator::DigToFetchChain < Evilution::Mutator::Base
16
+ NON_POSITIONAL = [Prism::SplatNode, Prism::KeywordHashNode, Prism::ForwardingArgumentsNode].freeze
17
+ private_constant :NON_POSITIONAL
18
+
19
+ def visit_call_node(node)
20
+ rewrite(node) if node.name == :dig && node.receiver && node.block.nil?
21
+ super
22
+ end
23
+
24
+ private
25
+
26
+ # Replace `dig(first, ` with `fetch(first).dig(` — up to the second key —
27
+ # keeping whatever opened the argument list (`(`, a newline, a space).
28
+ def rewrite(node)
29
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [first, second, *]]
30
+ return unless positional_keys?(node.arguments.arguments)
31
+
32
+ start = node.message_loc.start_offset
33
+ add_mutation(offset: start, length: second.start_offset - start, replacement: fetch_prefix(node, first), node: node)
34
+ end
35
+
36
+ def positional_keys?(keys)
37
+ keys.none? { |key| NON_POSITIONAL.any? { |type| key.is_a?(type) } }
38
+ end
39
+
40
+ def fetch_prefix(node, first)
41
+ message_end = node.message_loc.end_offset
42
+ opener = byteslice_source(message_end, first.start_offset - message_end)
43
+ "fetch(#{source_of(first)}).dig#{opener}"
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a double negation with its operand: `!!value` becomes `value`, and
6
+ # the same for the `not not value` keyword form.
7
+ #
8
+ # A survivor means no test distinguishes a truthy object from `true` (or nil
9
+ # from `false`) — the boolean coercion is never observed.
10
+ class Evilution::Mutator::Operator::DoubleNegationRemoval < Evilution::Mutator::Base
11
+ def visit_call_node(node)
12
+ inner = node.receiver
13
+ promote_child(node, inner.receiver) if negation?(node) && negation?(inner)
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def negation?(node)
20
+ node.is_a?(Prism::CallNode) && node.name == :!
21
+ end
22
+ end