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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +38 -0
- data/.rubocop_todo.yml +9 -0
- data/CHANGELOG.md +47 -0
- data/README.md +65 -7
- data/docs/architecture.md +35 -1
- data/lib/evilution/ast/parser.rb +1 -0
- data/lib/evilution/ast/pattern/filter.rb +1 -0
- data/lib/evilution/ast/pattern/parser.rb +1 -0
- data/lib/evilution/ast/pattern.rb +2 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
- data/lib/evilution/baseline.rb +37 -15
- data/lib/evilution/child_output.rb +1 -1
- data/lib/evilution/cli/command.rb +1 -0
- data/lib/evilution/cli/commands/tests_list.rb +4 -3
- data/lib/evilution/cli/commands.rb +2 -0
- data/lib/evilution/cli/dispatcher.rb +2 -0
- data/lib/evilution/cli/exit_guard.rb +2 -0
- data/lib/evilution/cli/parsed_args.rb +2 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -0
- data/lib/evilution/cli/parser/file_args.rb +2 -0
- data/lib/evilution/cli/parser/options_builder.rb +3 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
- data/lib/evilution/cli/parser.rb +1 -0
- data/lib/evilution/cli/printers/tests_list.rb +5 -4
- data/lib/evilution/cli/printers.rb +2 -0
- data/lib/evilution/cli/result.rb +2 -0
- data/lib/evilution/cli.rb +5 -0
- data/lib/evilution/config/builders.rb +2 -0
- data/lib/evilution/config/env_loader.rb +2 -0
- data/lib/evilution/config/file_loader.rb +1 -0
- data/lib/evilution/config/sources.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
- data/lib/evilution/config/validators/fail_fast.rb +1 -0
- data/lib/evilution/config/validators/hooks.rb +1 -0
- data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
- data/lib/evilution/config/validators/integration.rb +1 -0
- data/lib/evilution/config/validators/isolation.rb +1 -0
- data/lib/evilution/config/validators/jobs.rb +1 -0
- data/lib/evilution/config/validators/preload.rb +1 -0
- data/lib/evilution/config/validators/profile.rb +1 -0
- data/lib/evilution/config/validators/spec_mappings.rb +1 -0
- data/lib/evilution/config/validators/spec_pattern.rb +1 -0
- data/lib/evilution/config/validators/warmup.rb +17 -0
- data/lib/evilution/config/validators.rb +2 -0
- data/lib/evilution/config.rb +4 -3
- data/lib/evilution/coverage.rb +1 -1
- data/lib/evilution/coverage_example_filter.rb +1 -1
- data/lib/evilution/diagnostic.rb +1 -1
- data/lib/evilution/example_filter.rb +1 -1
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
- data/lib/evilution/integration/loading/test_load_path.rb +21 -13
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard.rb +6 -0
- data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
- data/lib/evilution/mutation.rb +1 -1
- data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
- data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
- data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
- data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
- data/lib/evilution/mutator/operator/bang_method.rb +11 -3
- data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
- data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
- data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
- data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
- data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
- data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
- data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
- data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
- data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
- data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
- data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
- data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
- data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
- data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
- data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
- data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
- data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
- data/lib/evilution/mutator/primitives.rb +25 -0
- data/lib/evilution/mutator/registry.rb +24 -1
- data/lib/evilution/parallel/pool.rb +1 -0
- data/lib/evilution/parallel_db_warning.rb +1 -1
- data/lib/evilution/rails_warmup.rb +61 -0
- data/lib/evilution/reporter/html/report.rb +1 -0
- data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
- data/lib/evilution/result/subject_scorer.rb +1 -0
- data/lib/evilution/runner/baseline_runner.rb +17 -7
- data/lib/evilution/runner/isolation_resolver.rb +23 -2
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +17 -10
- data/lib/evilution/source_ast_cache.rb +1 -1
- data/lib/evilution/spec_ast_cache.rb +1 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +23 -0
- 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
|
|
9
|
-
|
|
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: [
|
|
16
|
-
detect: [
|
|
17
|
-
any?: [
|
|
18
|
-
all?: [
|
|
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: [
|
|
34
|
-
max: [
|
|
35
|
-
min_by: [
|
|
36
|
-
max_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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|