evilution 1.4.0 → 1.5.1
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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +71 -0
- data/CHANGELOG.md +60 -0
- data/README.md +64 -22
- data/docs/architecture.md +56 -12
- data/docs/isolation.md +82 -0
- data/lib/evilution/ast/class_body.rb +101 -0
- data/lib/evilution/ast/included_block.rb +20 -6
- data/lib/evilution/ast/never_nil.rb +36 -0
- data/lib/evilution/ast/parser.rb +94 -31
- data/lib/evilution/ast/round_half_mode.rb +38 -0
- data/lib/evilution/ast/source_surgeon.rb +2 -3
- data/lib/evilution/ast/syntax_check.rb +56 -0
- data/lib/evilution/ast/uncovered_code.rb +7 -7
- data/lib/evilution/cache.rb +13 -3
- data/lib/evilution/cli/exit_guard.rb +26 -4
- data/lib/evilution/integration/base.rb +10 -2
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
- data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
- data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
- data/lib/evilution/integration/loading/own_methods.rb +87 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
- data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
- data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
- data/lib/evilution/isolation/fork.rb +82 -10
- data/lib/evilution/isolation/in_process.rb +79 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
- data/lib/evilution/mcp/info_tool.rb +3 -2
- data/lib/evilution/mutation.rb +18 -11
- data/lib/evilution/mutator/base.rb +27 -7
- data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
- data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
- data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
- data/lib/evilution/mutator/operator/array_literal.rb +29 -0
- data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
- data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
- data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
- data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
- data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
- data/lib/evilution/mutator/operator/float_literal.rb +54 -19
- data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
- data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
- data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
- data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
- data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
- data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
- data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
- data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
- data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
- data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
- data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
- data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
- data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
- data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
- data/lib/evilution/mutator/operator/string_literal.rb +29 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
- data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
- data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
- data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
- data/lib/evilution/mutator/primitives.rb +24 -2
- data/lib/evilution/mutator/receiver_positions.rb +79 -0
- data/lib/evilution/mutator/registry.rb +29 -2
- data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
- data/lib/evilution/parallel/work_queue/channel.rb +58 -5
- data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
- data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
- data/lib/evilution/parallel/work_queue/worker.rb +13 -4
- data/lib/evilution/parallel/work_queue.rb +15 -9
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
- data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
- data/lib/evilution/runner/subject_pipeline.rb +20 -51
- data/lib/evilution/runner.rb +1 -1
- data/lib/evilution/subject.rb +18 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +62 -0
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
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
|