evilution 1.4.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +70 -0
  3. data/CHANGELOG.md +50 -0
  4. data/README.md +64 -22
  5. data/docs/architecture.md +56 -12
  6. data/docs/isolation.md +82 -0
  7. data/lib/evilution/ast/class_body.rb +101 -0
  8. data/lib/evilution/ast/included_block.rb +20 -6
  9. data/lib/evilution/ast/never_nil.rb +36 -0
  10. data/lib/evilution/ast/parser.rb +94 -31
  11. data/lib/evilution/ast/round_half_mode.rb +38 -0
  12. data/lib/evilution/ast/source_surgeon.rb +2 -3
  13. data/lib/evilution/ast/syntax_check.rb +56 -0
  14. data/lib/evilution/ast/uncovered_code.rb +7 -7
  15. data/lib/evilution/cli/exit_guard.rb +26 -4
  16. data/lib/evilution/integration/base.rb +10 -2
  17. data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
  18. data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
  19. data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
  20. data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
  21. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  22. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  23. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  24. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  25. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  26. data/lib/evilution/isolation/fork.rb +82 -10
  27. data/lib/evilution/isolation/in_process.rb +79 -4
  28. data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
  29. data/lib/evilution/mcp/info_tool.rb +3 -2
  30. data/lib/evilution/mutation.rb +18 -11
  31. data/lib/evilution/mutator/base.rb +27 -7
  32. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  33. data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
  34. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  35. data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
  36. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  37. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  38. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  39. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  40. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  41. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  42. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  43. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  44. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  45. data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
  46. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  47. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  48. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  49. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  50. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  51. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  52. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  53. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  54. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  55. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  56. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  57. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  58. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  59. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  60. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  61. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  62. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  63. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  64. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  65. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  66. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  67. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  68. data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
  69. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  70. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  71. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  72. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  73. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  74. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  75. data/lib/evilution/mutator/primitives.rb +24 -2
  76. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  77. data/lib/evilution/mutator/registry.rb +29 -2
  78. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  79. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  80. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  81. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  82. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  83. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  84. data/lib/evilution/parallel/work_queue.rb +15 -9
  85. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  86. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  87. data/lib/evilution/runner/subject_pipeline.rb +20 -51
  88. data/lib/evilution/runner.rb +1 -1
  89. data/lib/evilution/subject.rb +18 -1
  90. data/lib/evilution/version.rb +1 -1
  91. data/lib/evilution.rb +62 -0
  92. metadata +32 -2
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+ require_relative "../../ast/class_body"
7
+
8
+ # Drop the `freeze` of a constant defined in a class or module body:
9
+ # `LIST = %w[a b].freeze` becomes `LIST = %w[a b]`.
10
+ #
11
+ # A survivor means no example tries to change the shared value, so nothing
12
+ # shows that it has to stay as it is.
13
+ #
14
+ # A constant is defined in the class body, outside every method, where no
15
+ # subject reaches it. The mutation is attributed to the method anchoring the
16
+ # enclosing body (see Evilution::AST::ClassBody); a top-level constant has no
17
+ # body to be attributed to. A `freeze` inside a method is left to the generic
18
+ # call operators, which already drop it.
19
+ class Evilution::Mutator::Operator::FreezeRemoval < Evilution::Mutator::Base
20
+ CONSTANT_WRITES = [Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::ConstantOrWriteNode].freeze
21
+
22
+ # Values that are frozen whether or not `freeze` is called.
23
+ ALWAYS_FROZEN = [
24
+ Prism::SymbolNode, Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::ImaginaryNode,
25
+ Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::RangeNode, Prism::RegularExpressionNode
26
+ ].freeze
27
+
28
+ # The comments a file opens with, where the magic comment has to be.
29
+ LEADING_COMMENTS = /\A(?:[ \t]*(?:#.*)?\n)*/
30
+ FROZEN_STRING_LITERALS = /^[ \t]*#.*frozen[_-]string[_-]literal:[ \t]*true/i
31
+ private_constant :CONSTANT_WRITES, :ALWAYS_FROZEN, :LEADING_COMMENTS, :FROZEN_STRING_LITERALS
32
+
33
+ def call(subject, filter: nil)
34
+ @subject = subject
35
+ @file_source = File.read(subject.file_path)
36
+ @mutations = []
37
+ @filter = filter
38
+
39
+ constant_writes_for(subject).each { |write| freezes_in(write).each { |freeze_call| remove(freeze_call) } }
40
+ @mutations
41
+ end
42
+
43
+ private
44
+
45
+ def remove(freeze_call)
46
+ add_mutation(
47
+ offset: freeze_call.location.start_offset,
48
+ length: freeze_call.location.length,
49
+ replacement: freeze_call.receiver.slice,
50
+ node: freeze_call
51
+ )
52
+ end
53
+
54
+ def constant_writes_for(subject)
55
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
56
+
57
+ Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
58
+ body.declarations { |node| CONSTANT_WRITES.include?(node.class) }
59
+ end
60
+ end
61
+
62
+ # Every `freeze` call under the node, outermost first.
63
+ def freezes_in(node)
64
+ nested = node.compact_child_nodes.flat_map { |child| freezes_in(child) }
65
+ removable_freeze?(node) ? [node, *nested] : nested
66
+ end
67
+
68
+ def removable_freeze?(node)
69
+ return false unless node.is_a?(Prism::CallNode) && node.name == :freeze
70
+ return false if node.receiver.nil? || node.arguments
71
+
72
+ !frozen_anyway?(node.receiver)
73
+ end
74
+
75
+ # A group has the value of its last expression: `(1..2)`.
76
+ def frozen_anyway?(receiver)
77
+ receiver = receiver.body.body.last while receiver.is_a?(Prism::ParenthesesNode) && receiver.body
78
+ return true if ALWAYS_FROZEN.include?(receiver.class)
79
+
80
+ receiver.is_a?(Prism::StringNode) && frozen_string_literals?
81
+ end
82
+
83
+ def frozen_string_literals?
84
+ @file_source[LEADING_COMMENTS].match?(FROZEN_STRING_LITERALS)
85
+ end
86
+ end
@@ -3,6 +3,10 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::HashLiteral < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
8
+ RENAMED_KEY = "__evilution_mutated__"
9
+
6
10
  def visit_hash_node(node)
7
11
  if node.elements.any?
8
12
  add_mutation(
@@ -18,8 +22,55 @@ class Evilution::Mutator::Operator::HashLiteral < Evilution::Mutator::Base
18
22
  replacement: "nil",
19
23
  node: node
20
24
  )
25
+
26
+ mutate_delete_pairs(node.elements)
27
+ mutate_rename_keys(node.elements)
21
28
  end
22
29
 
23
30
  super
24
31
  end
32
+
33
+ private
34
+
35
+ # Delete each key/value pair in turn: a survivor means no example checks
36
+ # that key. A single element is left to the `{}` replacement. A `**splat`
37
+ # is not a pair and stays. So does a pair holding a heredoc: its body sits
38
+ # after the closing brace, so one cut cannot take both, and the cut that
39
+ # does not parse is dropped.
40
+ def mutate_delete_pairs(elements)
41
+ return if elements.one?
42
+
43
+ elements.each_with_index do |element, index|
44
+ delete_element(elements, index) if element.is_a?(Prism::AssocNode)
45
+ end
46
+ end
47
+
48
+ # Rename each label key in turn (`a: 1`, `"a b": 1`): a survivor means no
49
+ # example reads the value by that name. A key written with a rocket
50
+ # (`:a => 1`, `"a" => 1`) is a literal of its own, and SymbolLiteral and
51
+ # StringLiteral already replace it.
52
+ def mutate_rename_keys(elements)
53
+ elements.each do |element|
54
+ rename_key(element) if element.is_a?(Prism::AssocNode) && label?(element.key)
55
+ end
56
+ end
57
+
58
+ def label?(key)
59
+ key.is_a?(Prism::SymbolNode) && !key.closing.nil? && key.closing.end_with?(":")
60
+ end
61
+
62
+ # A shorthand pair (`{ x: }`) reads its value from the key, so the value is
63
+ # written out to stay what it was.
64
+ def rename_key(pair)
65
+ key = pair.key
66
+ replacement = "#{RENAMED_KEY}:"
67
+ replacement += " #{key.unescaped}" if pair.value.is_a?(Prism::ImplicitNode)
68
+
69
+ add_mutation(
70
+ offset: key.location.start_offset,
71
+ length: key.location.length,
72
+ replacement: replacement,
73
+ node: key
74
+ )
75
+ end
25
76
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Take the tail of a list with `drop` instead of a range index:
6
+ # `list[n..-1]` and the endless `list[n..]` / `list[n...]` become
7
+ # `list.drop(n)`.
8
+ #
9
+ # The two differ only past the end: a start beyond the last element gives
10
+ # `nil` from the index and an empty array from `drop`. A survivor means no
11
+ # test reaches that boundary.
12
+ #
13
+ # A literal start of zero is skipped, never being out of range, and so is a
14
+ # negative literal, which `drop` rejects with ArgumentError. A range that
15
+ # stops short of the last element (`n...-1`, `n..-2`) is no tail at all. A
16
+ # read in void statement position is left to statement_deletion, and index
17
+ # writes are another method altogether.
18
+ #
19
+ # The receiver's type is unknown here: on a String the mutant raises
20
+ # NoMethodError, which any test reaching the line kills.
21
+ class Evilution::Mutator::Operator::IndexRangeToDrop < Evilution::Mutator::Base
22
+ LAST_INDEX = -1
23
+ private_constant :LAST_INDEX
24
+
25
+ def call(subject, **)
26
+ @void_statements = Set.new
27
+ super
28
+ end
29
+
30
+ def visit_statements_node(node)
31
+ @void_statements.merge(node.body[...-1])
32
+ super
33
+ end
34
+
35
+ def visit_call_node(node)
36
+ start = tail_start(node)
37
+ replace_span(node: node, target: node, replacement: drop_call(node, start)) if start
38
+ super
39
+ end
40
+
41
+ private
42
+
43
+ # Where the tail begins, when `node` reads from there to the last element.
44
+ # A beginless range has no start to hand to `drop`, and answers nil too.
45
+ def tail_start(node)
46
+ return unless node.name == :[] && !@void_statements.include?(node)
47
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [Prism::RangeNode => range]]
48
+
49
+ range.left if to_the_end?(range) && droppable_start?(range.left)
50
+ end
51
+
52
+ def to_the_end?(range)
53
+ stop = range.right
54
+ return true if stop.nil?
55
+
56
+ !range.exclude_end? && stop.is_a?(Prism::IntegerNode) && stop.value == LAST_INDEX
57
+ end
58
+
59
+ def droppable_start?(start)
60
+ !start.is_a?(Prism::IntegerNode) || start.value.positive?
61
+ end
62
+
63
+ def drop_call(node, start)
64
+ operator = node.safe_navigation? ? "&." : "."
65
+ "#{source_of(node.receiver)}#{operator}drop(#{source_of(start)})"
66
+ end
67
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Send an index read to `self` instead of its receiver: `hash[key]` becomes
6
+ # `self[key]`, arguments untouched.
7
+ #
8
+ # The survivor it targets lives in a class that defines its own `#[]` — a
9
+ # collection or a wrapper that also holds the thing it indexes — where the
10
+ # tests never tell the two lookups apart. Anywhere else the mutant raises
11
+ # NoMethodError, which any test reaching the line kills.
12
+ #
13
+ # Index writes are left alone: `a[b] = c` is a different method, and the
14
+ # operator-write forms (`a[b] ||= c`) are not calls at all.
15
+ class Evilution::Mutator::Operator::IndexReceiverToSelf < Evilution::Mutator::Base
16
+ def visit_call_node(node)
17
+ replace_span(node: node, target: node.receiver, replacement: "self") if node.name == :[]
18
+ super
19
+ end
20
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Ask whether the key is there instead of reading it: `config[:size]`
6
+ # becomes `config.key?(:size)`.
7
+ #
8
+ # The value turns into a boolean. A survivor means the tests only notice
9
+ # that something came back — a condition taken, a truthy result — and never
10
+ # what the lookup returned.
11
+ #
12
+ # Only a read with one plain argument qualifies, `key?` taking exactly one.
13
+ # An integer or range index is skipped: it points at an Array or a String,
14
+ # which has no `key?`, so the mutant would only raise NoMethodError. A read
15
+ # in void statement position is left to statement_deletion, its value being
16
+ # discarded either way. Index writes are another method altogether.
17
+ class Evilution::Mutator::Operator::IndexToKeyPredicate < Evilution::Mutator::Base
18
+ # Argument shapes `key?` cannot take as its one key.
19
+ NON_KEY_ARGUMENTS = [
20
+ Prism::SplatNode,
21
+ Prism::KeywordHashNode,
22
+ Prism::BlockArgumentNode,
23
+ Prism::ForwardingArgumentsNode,
24
+ Prism::IntegerNode,
25
+ Prism::RangeNode
26
+ ].freeze
27
+ private_constant :NON_KEY_ARGUMENTS
28
+
29
+ def call(subject, **)
30
+ @void_statements = Set.new
31
+ super
32
+ end
33
+
34
+ def visit_statements_node(node)
35
+ @void_statements.merge(node.body[...-1])
36
+ super
37
+ end
38
+
39
+ def visit_call_node(node)
40
+ key = key_argument(node)
41
+ replace_span(node: node, target: node, replacement: key_predicate(node, key)) if key
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ def key_argument(node)
48
+ return unless node.name == :[] && !@void_statements.include?(node)
49
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
50
+
51
+ argument unless NON_KEY_ARGUMENTS.any? { |type| argument.is_a?(type) }
52
+ end
53
+
54
+ def key_predicate(node, key)
55
+ operator = node.safe_navigation? ? "&." : "."
56
+ "#{source_of(node.receiver)}#{operator}key?(#{source_of(key)})"
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace an index write with the value it assigns: `cache[key] = value`
6
+ # becomes `value`.
7
+ #
8
+ # An index write evaluates to the assigned value, so the mutant returns just
9
+ # what the original did — only the store is gone. A survivor means the tests
10
+ # check what was computed and never that it was kept. That is what sets it
11
+ # apart from deleting the statement or turning it into `nil`, both of which
12
+ # also change the result.
13
+ #
14
+ # The result is kept for the assignment syntax only. The method-call
15
+ # spelling is mutated as well, but there it may change: `cache.[]=(key,
16
+ # value)` returns whatever `[]=` returns, and `cache&.[]=(key, value)` is
17
+ # `nil`, with `value` never evaluated, when the receiver is `nil`.
18
+ #
19
+ # A write in void statement position is skipped: with its value discarded,
20
+ # the mutant is statement_deletion's. Operator writes (`a[b] ||= c`) and
21
+ # index targets of a multiple assignment are not calls and are left alone.
22
+ class Evilution::Mutator::Operator::IndexWriteToValue < Evilution::Mutator::Base
23
+ def call(subject, **)
24
+ @void_statements = Set.new
25
+ super
26
+ end
27
+
28
+ def visit_statements_node(node)
29
+ @void_statements.merge(node.body[...-1])
30
+ super
31
+ end
32
+
33
+ def visit_call_node(node)
34
+ promote_child(node, argument_nodes(node).last) if node.name == :[]= && !@void_statements.include?(node)
35
+ super
36
+ end
37
+ end
@@ -3,23 +3,53 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::IntegerLiteral < Evilution::Mutator::Base
6
- def visit_integer_node(node)
7
- if node.value.zero?
8
- add_mutation_with_replacement(node, "1")
9
- elsif node.value == 1
10
- add_mutation_with_replacement(node, "0")
11
- else
12
- add_mutation_with_replacement(node, "0")
13
- add_mutation_with_replacement(node, (node.value + 1).to_s)
14
- end
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
8
+ # One value past each integer width, keyed by the boundary it crosses:
9
+ # int8, uint8, int16, uint16, int32, uint32, int64. A literal is replaced
10
+ # with the sentinel of the first boundary above its magnitude, so a
11
+ # survivor means nothing checks that the value still fits its width. The
12
+ # sentinels are safe primes: arithmetic, masking or shifting does not land
13
+ # on one by coincidence.
14
+ WIDTH_SENTINELS = {
15
+ 2**7 => 167,
16
+ 2**8 => 467,
17
+ 2**15 => 55_487,
18
+ 2**16 => 108_503,
19
+ 2**31 => 2_667_278_543,
20
+ 2**32 => 7_980_081_959,
21
+ 2**63 => 15_508_464_536_481_899_903
22
+ }.freeze
15
23
 
24
+ def visit_integer_node(node)
25
+ replacements_for(node.value).each { |value| add_mutation_with_replacement(node, value.to_s) }
16
26
  add_mutation_with_replacement(node, "nil")
17
27
 
18
28
  super
19
29
  end
20
30
 
31
+ # A complex literal (`5i`) is replaced as a whole by ComplexLiteral. Its
32
+ # numeric part is not a literal of its own: `nili` is not a value.
33
+ def visit_imaginary_node(_node); end
34
+
21
35
  private
22
36
 
37
+ def replacements_for(value)
38
+ [*neighbours_of(value), width_sentinel_for(value)].compact
39
+ end
40
+
41
+ def neighbours_of(value)
42
+ return [1, -1] if value.zero?
43
+ return [0] if value == 1
44
+
45
+ [0, value + 1, value - 1].uniq
46
+ end
47
+
48
+ def width_sentinel_for(value)
49
+ _boundary, sentinel = WIDTH_SENTINELS.find { |boundary, _| boundary > value.abs }
50
+ sentinel
51
+ end
52
+
23
53
  def add_mutation_with_replacement(node, replacement)
24
54
  add_mutation(offset: node.location.start_offset, length: node.location.length, replacement:, node:)
25
55
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Turn a memoizing assignment into a plain one: `@x ||= expr` becomes
6
+ # `@x = expr`, so the value is computed again on every call.
7
+ #
8
+ # A survivor means no example tells one computation from several: nothing
9
+ # counts the calls behind the value, and nothing depends on getting the same
10
+ # object back.
11
+ class Evilution::Mutator::Operator::MemoizationBreak < Evilution::Mutator::Base
12
+ def visit_instance_variable_or_write_node(node)
13
+ operator = node.operator_loc
14
+ add_mutation(offset: operator.start_offset, length: operator.length, replacement: "=", node: node)
15
+
16
+ super
17
+ end
18
+ end
@@ -3,7 +3,15 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../operator"
6
-
6
+ require_relative "../../ast/class_body"
7
+
8
+ # Drop a mixin from a class or module body: `include`, `extend` and `prepend`
9
+ # calls are removed.
10
+ #
11
+ # The calls sit in the class body, outside every method, where no subject
12
+ # reaches them. They are attributed to the method anchoring the enclosing
13
+ # body (see Evilution::AST::ClassBody). A mixin call inside a method is left
14
+ # to the generic call operators.
7
15
  class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
8
16
  MIXIN_METHODS = %i[include extend prepend].freeze
9
17
 
@@ -13,22 +21,22 @@ class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
13
21
  @mutations = []
14
22
  @filter = filter
15
23
 
16
- enclosing = find_target_scope(subject)
17
- return @mutations unless enclosing
18
-
19
- find_mixin_calls(enclosing).each { |call_node| emit_mixin_removal(call_node) }
24
+ mixin_calls_for(subject).each { |call_node| emit_mixin_removal(call_node) }
20
25
  @mutations
21
26
  end
22
27
 
23
28
  private
24
29
 
25
- def find_target_scope(subject)
30
+ def mixin_calls_for(subject)
26
31
  tree = self.class.parsed_tree_for(subject.file_path, @file_source)
27
- enclosing = find_enclosing_scope(tree, subject.line_number)
28
- return nil unless enclosing
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).flat_map do |body|
34
+ body.declarations { |node| mixin_call?(node) }
35
+ end
36
+ end
37
+
38
+ def mixin_call?(node)
39
+ node.is_a?(Prism::CallNode) && MIXIN_METHODS.include?(node.name) && node.receiver.nil?
32
40
  end
33
41
 
34
42
  def emit_mixin_removal(call_node)
@@ -39,49 +47,4 @@ class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
39
47
  node: call_node
40
48
  )
41
49
  end
42
-
43
- def find_enclosing_scope(tree, target_line)
44
- finder = ScopeFinder.new(target_line)
45
- finder.visit(tree)
46
- finder.result
47
- end
48
-
49
- def find_first_method_line(scope_node)
50
- return nil unless scope_node.body
51
-
52
- scope_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
- def find_mixin_calls(scope_node)
59
- return [] unless scope_node.body
60
-
61
- scope_node.body.body.select do |node|
62
- node.is_a?(Prism::CallNode) &&
63
- MIXIN_METHODS.include?(node.name) &&
64
- node.receiver.nil?
65
- end
66
- end
67
-
68
- # Visitor to find the ClassNode or ModuleNode enclosing a given line number.
69
- class ScopeFinder < Prism::Visitor
70
- attr_reader :result
71
-
72
- def initialize(target_line)
73
- @target_line = target_line
74
- @result = nil
75
- end
76
-
77
- def visit_class_node(node)
78
- @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
79
- super
80
- end
81
-
82
- def visit_module_node(node)
83
- @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
84
- super
85
- end
86
- end
87
50
  end
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::NilReplacement < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  REPLACEMENTS = %w[true false 0 ""].freeze
7
9
 
8
10
  def visit_nil_node(node)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Retarget an operator write on an attribute at the instance variable it
6
+ # goes through: `@account.balance += amount` becomes `@account += amount`.
7
+ #
8
+ # The value is still computed and still assigned, only to the wrong place:
9
+ # the object's attribute is left as it was and the variable is replaced. A
10
+ # survivor means the tests exercise the line without ever looking at what it
11
+ # was meant to change. Where the object does not answer the operator the
12
+ # mutant raises NoMethodError, which any test reaching the line kills.
13
+ #
14
+ # Only a receiver that is an instance variable qualifies. `self` cannot be
15
+ # assigned to, and behind a longer receiver (`@user.account.balance += x`)
16
+ # sits another attribute, not a variable.
17
+ class Evilution::Mutator::Operator::OperatorWriteReceiverPromotion < Evilution::Mutator::Base
18
+ def visit_call_operator_write_node(node)
19
+ promote_receiver(node) if node.receiver.is_a?(Prism::InstanceVariableReadNode)
20
+ super
21
+ end
22
+
23
+ private
24
+
25
+ # Replaces `@account.balance`, up to the end of the attribute's name, with
26
+ # `@account`. What is left is an operator write on a variable, which always
27
+ # parses.
28
+ def promote_receiver(node)
29
+ offset = node.start_offset
30
+
31
+ add_mutation(
32
+ offset: offset,
33
+ length: node.message_loc.end_offset - offset,
34
+ replacement: source_of(node.receiver),
35
+ node: node
36
+ )
37
+ end
38
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::RangeReplacement < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  def visit_range_node(node)
7
9
  replacement = node.operator == ".." ? "..." : ".."
8
10
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a rational literal (`5r`, `1.5r`) with `0r`, `1r`, its two
6
+ # neighbours (`value + 1r`, `value - 1r`) and `nil`.
7
+ class Evilution::Mutator::Operator::RationalLiteral < Evilution::Mutator::Base
8
+ extend Evilution::Mutator::ConstantValueSubjects
9
+
10
+ def visit_rational_node(node)
11
+ replacements_for(node.value).each { |value| add_mutation_with_replacement(node, literal_for(value)) }
12
+ add_mutation_with_replacement(node, "nil")
13
+
14
+ super
15
+ end
16
+
17
+ # A complex literal (`3ri`) is replaced as a whole by ComplexLiteral. Its
18
+ # rational part is not a literal of its own: `nili` is not a value.
19
+ def visit_imaginary_node(_node); end
20
+
21
+ private
22
+
23
+ def replacements_for(value)
24
+ [0r, 1r, value + 1, value - 1].uniq - [value]
25
+ end
26
+
27
+ # A rational literal is an integer or a decimal, so its value and both
28
+ # neighbours always have a finite decimal form.
29
+ def literal_for(value)
30
+ return "#{value.numerator}r" if value.denominator == 1
31
+
32
+ digits = (1..).find { |count| ((10**count) % value.denominator).zero? }
33
+ whole, fraction = (value.abs * (10**digits)).to_i.divmod(10**digits)
34
+ "#{"-" if value.negative?}#{whole}.#{fraction.to_s.rjust(digits, "0")}r"
35
+ end
36
+
37
+ def add_mutation_with_replacement(node, replacement)
38
+ add_mutation(offset: node.location.start_offset, length: node.location.length, replacement:, node:)
39
+ end
40
+ end
@@ -39,7 +39,7 @@ class Evilution::Mutator::Operator::ReduceToSum < Evilution::Mutator::Base
39
39
  # The arguments besides the `+` operand — at most an initial value — when
40
40
  # the call is a `+` reduction; nil otherwise.
41
41
  def initial_arguments(node)
42
- arguments = node.arguments ? node.arguments.arguments : []
42
+ arguments = argument_nodes(node)
43
43
 
44
44
  if plus_block_pass?(node.block)
45
45
  arguments unless plus_symbol?(arguments.last)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Widen what a rescue clause catches: `rescue KeyError` becomes
6
+ # `rescue StandardError` and `rescue Exception`.
7
+ #
8
+ # A survivor means no example raises a sibling error that has to get past
9
+ # the handler, so nothing shows the clause needs to be as narrow as it is.
10
+ #
11
+ # A bare `rescue` is `rescue StandardError`, so the two are never offered as
12
+ # mutants of each other; both go to `Exception` only. So does a clause naming
13
+ # a core class outside `StandardError` (`Interrupt`, `LoadError`), which
14
+ # `StandardError` would not widen but replace. A class of the program's own
15
+ # is taken to be a `StandardError`. A clause that already names `Exception`
16
+ # cannot be widened. A rescue modifier (`x rescue y`) takes no class and is
17
+ # left alone.
18
+ class Evilution::Mutator::Operator::RescueClassWidening < Evilution::Mutator::Base
19
+ WIDER = %w[StandardError Exception].freeze
20
+
21
+ # The core classes that descend from Exception but not from StandardError.
22
+ OUTSIDE_STANDARD_ERROR = %w[
23
+ NoMemoryError ScriptError LoadError NotImplementedError SyntaxError SecurityError SignalException Interrupt
24
+ SystemExit SystemStackError
25
+ ].freeze
26
+
27
+ def visit_rescue_node(node)
28
+ wider_than(node.exceptions).each { |name| widen(node, name) }
29
+ super
30
+ end
31
+
32
+ private
33
+
34
+ # The classes of WIDER above everything the clause lists.
35
+ def wider_than(exceptions)
36
+ listed = exceptions.map { |exception| exception.slice.delete_prefix("::") }
37
+ return [] if listed.include?(WIDER.last)
38
+ return [WIDER.last] if listed.empty? || listed.include?(WIDER.first) || listed.intersect?(OUTSIDE_STANDARD_ERROR)
39
+
40
+ WIDER
41
+ end
42
+
43
+ def widen(node, name)
44
+ exceptions = node.exceptions
45
+ return insert_after_keyword(node, name) if exceptions.empty?
46
+
47
+ offset = exceptions.first.location.start_offset
48
+ add_mutation(offset:, length: exceptions.last.location.end_offset - offset, replacement: name, node:)
49
+ end
50
+
51
+ def insert_after_keyword(node, name)
52
+ add_mutation(offset: node.keyword_loc.end_offset, length: 0, replacement: " #{name}", node:)
53
+ end
54
+ end