evilution 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +58 -0
- data/.rubocop_todo.yml +9 -0
- data/CHANGELOG.md +86 -0
- data/README.md +202 -14
- data/docs/architecture.md +54 -5
- data/docs/isolation.md +3 -4
- data/exe/evil +8 -1
- data/exe/evilution +8 -1
- data/lib/evilution/ast/local_reads.rb +43 -0
- 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 +61 -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 +4 -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 +6 -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 +7 -5
- data/lib/evilution/coverage.rb +1 -1
- data/lib/evilution/coverage_example_filter.rb +1 -1
- data/lib/evilution/diagnostic.rb +22 -0
- data/lib/evilution/example_filter.rb +1 -1
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
- data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
- data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
- data/lib/evilution/integration/loading/test_load_path.rb +21 -13
- data/lib/evilution/integration/minitest.rb +2 -1
- data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +5 -1
- 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/integration/rspec/unresolved_spec_warner.rb +2 -1
- data/lib/evilution/integration/rspec.rb +48 -2
- data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
- data/lib/evilution/isolation/fork.rb +29 -8
- 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/block_destructuring_expansion.rb +85 -0
- data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -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/forwarding_super_to_explicit.rb +71 -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/method_body_replacement.rb +10 -1
- data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
- data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
- data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -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/operator/typed_default_return.rb +84 -0
- data/lib/evilution/mutator/primitives.rb +25 -0
- data/lib/evilution/mutator/registry.rb +32 -1
- data/lib/evilution/parallel/pool.rb +1 -0
- data/lib/evilution/parallel_db_warning.rb +1 -1
- data/lib/evilution/process_supervisor.rb +20 -8
- data/lib/evilution/rails_warmup.rb +61 -0
- data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
- data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
- data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
- data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
- data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
- data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
- data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
- data/lib/evilution/reporter/cli/trailer.rb +11 -7
- data/lib/evilution/reporter/cli.rb +20 -4
- data/lib/evilution/reporter/html/report.rb +1 -0
- data/lib/evilution/reporter/json/subjects.rb +29 -0
- data/lib/evilution/reporter/json.rb +23 -1
- data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
- data/lib/evilution/result/mutation_result.rb +3 -2
- data/lib/evilution/result/neutral_reason.rb +35 -0
- data/lib/evilution/result/subject_score.rb +28 -0
- data/lib/evilution/result/subject_scorer.rb +38 -0
- data/lib/evilution/result/summary.rb +44 -2
- data/lib/evilution/runner/baseline_runner.rb +17 -7
- data/lib/evilution/runner/canary.rb +52 -5
- data/lib/evilution/runner/isolation_resolver.rb +23 -2
- data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +23 -10
- data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
- data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
- data/lib/evilution/runner/mutation_executor.rb +32 -4
- data/lib/evilution/runner/report_publisher.rb +31 -9
- data/lib/evilution/runner/target_spec_audit.rb +43 -0
- data/lib/evilution/runner.rb +10 -1
- 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 +35 -0
- metadata +50 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a call with its only argument: `normalize(value)` becomes `value`.
|
|
6
|
+
#
|
|
7
|
+
# A survivor means the method's transformation of its input is never
|
|
8
|
+
# observed — the tests would pass if the value went through untouched.
|
|
9
|
+
# Distinct from method_call_removal, which keeps the receiver instead.
|
|
10
|
+
#
|
|
11
|
+
# Only identifier-named methods with a single positional argument qualify.
|
|
12
|
+
# Operator methods (`a + b`, `a[i]`) are left to binary operand promotion,
|
|
13
|
+
# attribute writes to attribute_write_to_read, and a call in void statement
|
|
14
|
+
# position to statement_deletion, since there the promoted argument's value
|
|
15
|
+
# is discarded anyway.
|
|
16
|
+
class Evilution::Mutator::Operator::ArgumentPropagation < Evilution::Mutator::Base
|
|
17
|
+
NON_POSITIONAL = [
|
|
18
|
+
Prism::SplatNode,
|
|
19
|
+
Prism::KeywordHashNode,
|
|
20
|
+
Prism::BlockArgumentNode,
|
|
21
|
+
Prism::ForwardingArgumentsNode
|
|
22
|
+
].freeze
|
|
23
|
+
private_constant :NON_POSITIONAL
|
|
24
|
+
|
|
25
|
+
IDENTIFIER = /\A[[:alpha:]_]/
|
|
26
|
+
private_constant :IDENTIFIER
|
|
27
|
+
|
|
28
|
+
def call(subject, **)
|
|
29
|
+
@void_statements = Set.new
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def visit_statements_node(node)
|
|
34
|
+
@void_statements.merge(node.body[...-1])
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def visit_call_node(node)
|
|
39
|
+
promote_child(node, sole_positional_argument(node)) if propagatable?(node)
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def sole_positional_argument(node)
|
|
46
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
|
|
47
|
+
|
|
48
|
+
argument unless NON_POSITIONAL.any? { |type| argument.is_a?(type) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def propagatable?(node)
|
|
52
|
+
node.name.match?(IDENTIFIER) && !node.attribute_write? && !@void_statements.include?(node)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace an `Array()` coercion with an array literal: `Array(value)` becomes
|
|
6
|
+
# `[value]`, and the same for `Kernel.Array(value)` / `Kernel::Array(value)`.
|
|
7
|
+
#
|
|
8
|
+
# The two agree only on a plain scalar. `Array(nil)` is `[]` where `[nil]`
|
|
9
|
+
# is not, an array or hash is converted rather than wrapped, and anything
|
|
10
|
+
# responding to `to_ary` / `to_a` is unpacked. A survivor means no test
|
|
11
|
+
# passes nil or a collection through the coercion.
|
|
12
|
+
class Evilution::Mutator::Operator::ArrayCoercionToLiteral < Evilution::Mutator::Base
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
rewrite(node) if node.name == :Array && kernel_receiver?(node.receiver) && node.block.nil?
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def rewrite(node)
|
|
21
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
|
|
22
|
+
return if argument.is_a?(Prism::SplatNode)
|
|
23
|
+
|
|
24
|
+
replace_span(node: node, target: node, replacement: "[#{source_of(argument)}]")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def kernel_receiver?(receiver)
|
|
28
|
+
case receiver
|
|
29
|
+
when nil then true
|
|
30
|
+
when Prism::ConstantReadNode then receiver.name == :Kernel
|
|
31
|
+
when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Kernel
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace an attribute or index write with the matching read: `a.foo = b`
|
|
6
|
+
# becomes `a.foo`, `a[i] = b` becomes `a[i]`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means nothing observes the write. Only positions
|
|
9
|
+
# statement_deletion does not reach are mutated: a write that is one of
|
|
10
|
+
# several statements in a body is already deleted there, and turning it into
|
|
11
|
+
# a read differs from that only by the reader call.
|
|
12
|
+
class Evilution::Mutator::Operator::AttributeWriteToRead < Evilution::Mutator::Base
|
|
13
|
+
def call(subject, **)
|
|
14
|
+
@deletable_statements = Set.new
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def visit_statements_node(node)
|
|
19
|
+
@deletable_statements.merge(node.body) if node.body.length > 1
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_call_node(node)
|
|
24
|
+
mutate_to_read(node) if node.attribute_write? && !@deletable_statements.include?(node)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def mutate_to_read(node)
|
|
31
|
+
read_length = node.message_loc.end_offset - node.start_offset
|
|
32
|
+
replace_span(node: node, target: node, replacement: byteslice_source(node.start_offset, read_length))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
require_relative "../operator"
|
|
4
4
|
|
|
5
5
|
class Evilution::Mutator::Operator::BangMethod < Evilution::Mutator::Base
|
|
6
|
+
# Methods that have an in-place bang twin on at least one of String, Array,
|
|
7
|
+
# Hash or Set (the same set on every supported Ruby, 3.3+), plus `update` /
|
|
8
|
+
# `save` for ActiveRecord. String's `succ` / `next` are left out: those
|
|
9
|
+
# names are far more often called on Integer, which has no bang twin.
|
|
6
10
|
KNOWN_BANG_PAIRS = %i[
|
|
7
|
-
sort map collect select reject uniq compact flatten
|
|
8
|
-
shuffle reverse slice
|
|
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,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
|
|
7
|
+
# Flatten a destructuring group in a block's parameter list:
|
|
8
|
+
# `pairs.each_with_index { |(key, value), index| ... }` becomes
|
|
9
|
+
# `pairs.each_with_index { |key, value, index| ... }`.
|
|
10
|
+
#
|
|
11
|
+
# The two forms bind differently once the group has a sibling: given `[1, 2]`
|
|
12
|
+
# and `3`, the grouped form binds key=1, value=2, index=3, while the flat form
|
|
13
|
+
# binds key=[1, 2], value=3, index=nil. A survivor means nothing asserts the
|
|
14
|
+
# shape of what the yielder hands over.
|
|
15
|
+
#
|
|
16
|
+
# A lone group is left alone. With one parameter and a single yielded array —
|
|
17
|
+
# what `each` over pairs, and `Hash#each`, actually yield — both forms bind the
|
|
18
|
+
# same values, so the mutation would be behaviour-preserving and survive every
|
|
19
|
+
# suite. The forms do diverge there for a yielder that passes several values,
|
|
20
|
+
# but which one a block is handed cannot be known from the source, and a
|
|
21
|
+
# guaranteed survivor on the common idiom is the worse trade.
|
|
22
|
+
#
|
|
23
|
+
# Methods and lambdas are left alone as well: both bind by arity, so flattening
|
|
24
|
+
# a group in their signatures raises ArgumentError at every call site instead of
|
|
25
|
+
# changing how arguments are distributed.
|
|
26
|
+
class Evilution::Mutator::Operator::BlockDestructuringExpansion < Evilution::Mutator::Base
|
|
27
|
+
def visit_block_node(node)
|
|
28
|
+
expand_groups(node)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def expand_groups(node)
|
|
35
|
+
parameters = node.parameters
|
|
36
|
+
return unless parameters.is_a?(Prism::BlockParametersNode)
|
|
37
|
+
|
|
38
|
+
inner = parameters.parameters
|
|
39
|
+
return unless inner.is_a?(Prism::ParametersNode)
|
|
40
|
+
return unless siblings?(inner)
|
|
41
|
+
|
|
42
|
+
groups(inner).each { |group| flatten_group(group) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Both lists can hold a destructuring group: `|(a, b), c|` puts it in
|
|
46
|
+
# requireds, `|*rest, (a, b)|` in posts.
|
|
47
|
+
def groups(parameters)
|
|
48
|
+
[*parameters.requireds, *parameters.posts].grep(Prism::MultiTargetNode)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def siblings?(parameters)
|
|
52
|
+
parameter_count(parameters) > 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Block-local variables (`|a; tmp|`) are not parameters and so are not
|
|
56
|
+
# siblings — they change nothing about how arguments bind.
|
|
57
|
+
def parameter_count(parameters)
|
|
58
|
+
[
|
|
59
|
+
*parameters.requireds,
|
|
60
|
+
*parameters.optionals,
|
|
61
|
+
parameters.rest,
|
|
62
|
+
*parameters.posts,
|
|
63
|
+
*parameters.keywords,
|
|
64
|
+
parameters.keyword_rest,
|
|
65
|
+
parameters.block
|
|
66
|
+
].compact.length
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Only the group's own parentheses go; anything nested inside keeps its own.
|
|
70
|
+
# A group written in a block's parameter list always carries them — the
|
|
71
|
+
# parenthesis-free MultiTargetNode belongs to multiple assignment, which is
|
|
72
|
+
# not reachable from here.
|
|
73
|
+
def flatten_group(group)
|
|
74
|
+
lparen = group.lparen_loc
|
|
75
|
+
rparen = group.rparen_loc
|
|
76
|
+
|
|
77
|
+
inner_start = lparen.start_offset + lparen.length
|
|
78
|
+
add_mutation(
|
|
79
|
+
offset: group.location.start_offset,
|
|
80
|
+
length: group.location.length,
|
|
81
|
+
replacement: byteslice_source(inner_start, rparen.start_offset - inner_start),
|
|
82
|
+
node: group
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
|
|
7
|
+
# Drop the single parameter of a block: `users.each { |u| touch(u) }` becomes
|
|
8
|
+
# `users.each { touch(u) }`.
|
|
9
|
+
#
|
|
10
|
+
# A block tolerates being handed more arguments than it declares, so the mutant
|
|
11
|
+
# runs until the body reads the dropped name, where it raises NameError. A
|
|
12
|
+
# survivor therefore means the block never ran on a path the suite asserts —
|
|
13
|
+
# the collection was empty, or the call was stubbed away.
|
|
14
|
+
#
|
|
15
|
+
# Not to be confused with BlockParamRemoval, which removes a method's `&block`
|
|
16
|
+
# parameter from its signature.
|
|
17
|
+
#
|
|
18
|
+
# Two shapes are deliberately left alone. A parameter the body never reads makes
|
|
19
|
+
# the mutation behaviour-preserving, so it would survive every suite and report
|
|
20
|
+
# a coverage gap that is not there. A lambda checks its arity, so dropping its
|
|
21
|
+
# parameter raises ArgumentError on every call, whatever the body does.
|
|
22
|
+
class Evilution::Mutator::Operator::BlockParameterDrop < Evilution::Mutator::Base
|
|
23
|
+
def visit_block_node(node)
|
|
24
|
+
drop_parameter(node)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def drop_parameter(node)
|
|
31
|
+
return unless droppable?(node)
|
|
32
|
+
|
|
33
|
+
location = node.parameters.location
|
|
34
|
+
leading = leading_space(location)
|
|
35
|
+
|
|
36
|
+
add_mutation(
|
|
37
|
+
offset: location.start_offset - leading,
|
|
38
|
+
length: location.length + leading,
|
|
39
|
+
replacement: "",
|
|
40
|
+
node: node
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def droppable?(node)
|
|
45
|
+
parameters = node.parameters
|
|
46
|
+
return false unless parameters.is_a?(Prism::BlockParametersNode)
|
|
47
|
+
return false unless parameters.locals.empty?
|
|
48
|
+
|
|
49
|
+
names = single_parameter_names(parameters.parameters)
|
|
50
|
+
names.any? { |name| body_reads?(node.body, name) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The space the block keyword leaves in front of the parameters goes with
|
|
54
|
+
# them, so `{ |u| body }` mutates to `{ body }` and `do |u|` to `do` rather
|
|
55
|
+
# than leaving a double space or a trailing one.
|
|
56
|
+
def leading_space(location)
|
|
57
|
+
preceding = @file_source.byteslice(location.start_offset - 1, 1)
|
|
58
|
+
preceding == " " ? 1 : 0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The names bound by a lone required parameter, which is either a plain name
|
|
62
|
+
# or a destructuring target (`|(key, value)|`). Anything else — a second
|
|
63
|
+
# parameter, an optional, a rest, a keyword, a block pass, or the implicit
|
|
64
|
+
# rest Prism reports for the trailing comma in `|key,|` — means this is not a
|
|
65
|
+
# single-parameter block, and numbered parameters carry no node to remove.
|
|
66
|
+
def single_parameter_names(parameters)
|
|
67
|
+
return [] unless parameters.is_a?(Prism::ParametersNode)
|
|
68
|
+
return [] unless parameters.requireds.length == 1
|
|
69
|
+
return [] unless other_parameters_absent?(parameters)
|
|
70
|
+
|
|
71
|
+
parameter_names(parameters.requireds.first)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Post-required parameters are not checked: block syntax only produces them
|
|
75
|
+
# after a rest parameter, which is rejected here anyway.
|
|
76
|
+
def other_parameters_absent?(parameters)
|
|
77
|
+
parameters.optionals.empty? && parameters.keywords.empty? &&
|
|
78
|
+
parameters.rest.nil? && parameters.keyword_rest.nil? && parameters.block.nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parameter_names(node)
|
|
82
|
+
case node
|
|
83
|
+
when Prism::RequiredParameterNode then [node.name.to_s]
|
|
84
|
+
when Prism::MultiTargetNode then node.lefts.flat_map { |child| parameter_names(child) }
|
|
85
|
+
else []
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# An underscore-prefixed name announces a parameter that is not meant to be
|
|
90
|
+
# read, so it is treated as unused even where the body happens to mention it.
|
|
91
|
+
def body_reads?(body, name)
|
|
92
|
+
return false if name.start_with?("_")
|
|
93
|
+
|
|
94
|
+
Evilution::AST::LocalReads.new.call(body, name)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a method call with `nil`: `user.name` becomes `nil`, and the same
|
|
6
|
+
# for implicit-self calls, operator calls and calls carrying a block.
|
|
7
|
+
#
|
|
8
|
+
# The broadest value-level probe on a call site. The survivor it targets is a
|
|
9
|
+
# call whose result is never asserted — the test runs the code but checks
|
|
10
|
+
# nothing that depends on what the call returned.
|
|
11
|
+
#
|
|
12
|
+
# Three positions are skipped as noise. A call in void statement position
|
|
13
|
+
# (every statement of a body except the last) has no value to replace;
|
|
14
|
+
# deleting it is statement_deletion's job. A call used as the receiver of
|
|
15
|
+
# another call turns into `nil.foo`, a NoMethodError any test that reaches it
|
|
16
|
+
# kills; the outer call is nil-ified instead. An attribute or index write
|
|
17
|
+
# (`a.b = c`, `a[i] = c`) is a side effect, not a value.
|
|
18
|
+
class Evilution::Mutator::Operator::CallToNil < Evilution::Mutator::Base
|
|
19
|
+
def call(subject, **)
|
|
20
|
+
@skipped_calls = Set.new
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def visit_statements_node(node)
|
|
25
|
+
@skipped_calls.merge(node.body[...-1])
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def visit_call_node(node)
|
|
30
|
+
@skipped_calls.add(node.receiver)
|
|
31
|
+
mutate_to_nil(node) unless @skipped_calls.include?(node) || node.attribute_write?
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a conversion with an empty value of its target type: `value.to_a`
|
|
6
|
+
# becomes `[]`, `value.to_h` becomes `{}`, `value.to_s` becomes `""`, and the
|
|
7
|
+
# same for the implicit `to_ary` / `to_hash` / `to_str` forms.
|
|
8
|
+
#
|
|
9
|
+
# The type stays right, so a survivor means tests check what kind of value
|
|
10
|
+
# comes out but never what it contains.
|
|
11
|
+
#
|
|
12
|
+
# An empty hash that is the first argument of an unparenthesized call,
|
|
13
|
+
# `yield` or `super` is written `({})`: bare `{}` there would be parsed as a
|
|
14
|
+
# block. A nil receiver is skipped — its conversion is already empty.
|
|
15
|
+
class Evilution::Mutator::Operator::CoercionEmptying < Evilution::Mutator::Base
|
|
16
|
+
EMPTY_VALUES = {
|
|
17
|
+
to_a: "[]", to_ary: "[]",
|
|
18
|
+
to_h: "{}", to_hash: "{}",
|
|
19
|
+
to_s: '""', to_str: '""'
|
|
20
|
+
}.freeze
|
|
21
|
+
private_constant :EMPTY_VALUES
|
|
22
|
+
|
|
23
|
+
def call(subject, **)
|
|
24
|
+
@bare_first_arguments = Set.new
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def visit_call_node(node)
|
|
29
|
+
note_bare_first_argument(node, node.opening_loc)
|
|
30
|
+
empty(node) if emptiable?(node)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def visit_yield_node(node)
|
|
35
|
+
note_bare_first_argument(node, node.lparen_loc)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def visit_super_node(node)
|
|
40
|
+
note_bare_first_argument(node, node.lparen_loc)
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def note_bare_first_argument(node, opening)
|
|
47
|
+
@bare_first_arguments.add(node.arguments.arguments.first) if opening.nil? && node.arguments
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def emptiable?(node)
|
|
51
|
+
EMPTY_VALUES.key?(node.name) && node.receiver && !node.receiver.is_a?(Prism::NilNode) &&
|
|
52
|
+
node.arguments.nil? && node.block.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def empty(node)
|
|
56
|
+
value = EMPTY_VALUES.fetch(node.name)
|
|
57
|
+
value = "(#{value})" if value == "{}" && @bare_first_arguments.include?(node)
|
|
58
|
+
replace_span(node: node, target: node, replacement: value)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -12,10 +12,10 @@ class Evilution::Mutator::Operator::CollectionReplacement < Evilution::Mutator::
|
|
|
12
12
|
collect: [:each],
|
|
13
13
|
sort: [:sort_by],
|
|
14
14
|
sort_by: [:sort],
|
|
15
|
-
find: [
|
|
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)
|