evilution 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +132 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +127 -0
- data/README.md +136 -38
- data/docs/architecture.md +135 -11
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +121 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/class_body.rb +101 -0
- data/lib/evilution/ast/included_block.rb +41 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/never_nil.rb +36 -0
- data/lib/evilution/ast/parser.rb +187 -21
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/round_half_mode.rb +38 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -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 +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/exit_guard.rb +26 -4
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +11 -1
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
- data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
- 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/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +84 -11
- data/lib/evilution/isolation/in_process.rb +100 -8
- data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
- data/lib/evilution/mcp/info_tool.rb +3 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +21 -9
- data/lib/evilution/mutator/base.rb +69 -5
- data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
- data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +10 -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/comparison_operand_swap.rb +54 -0
- 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 +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- 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/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- 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_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- 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/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -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/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -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/send_mutation.rb +2 -0
- 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 +41 -24
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- 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 +58 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- 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/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
- data/lib/evilution/runner/subject_pipeline.rb +57 -43
- data/lib/evilution/runner.rb +5 -2
- data/lib/evilution/subject.rb +25 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +90 -0
- data/script/memory_check +62 -16
- metadata +113 -4
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Swap the values of neighbouring keyword arguments, keeping the keys:
|
|
6
|
+
# `compute(x: a, y: b)` becomes `compute(x: b, y: a)`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means no example tells the two values apart — the keyword form
|
|
9
|
+
# of the defect ArgumentOrderPermutation probes for positional arguments.
|
|
10
|
+
#
|
|
11
|
+
# Only keyword arguments written in the call are swapped: a braced hash is a
|
|
12
|
+
# single positional argument whose pairs are data. A double splat stays in
|
|
13
|
+
# place, and identical values are not swapped, since that would reproduce the
|
|
14
|
+
# original call. A shorthand key (`compute(a:, b:)`) has no value of its own
|
|
15
|
+
# to move, so the swap spells it out: `compute(a: b, b: a)`.
|
|
16
|
+
class Evilution::Mutator::Operator::KeywordValueSwap < Evilution::Mutator::Base
|
|
17
|
+
def visit_call_node(node)
|
|
18
|
+
swap_keyword_values(node)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def visit_super_node(node)
|
|
23
|
+
swap_keyword_values(node)
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def visit_yield_node(node)
|
|
28
|
+
swap_keyword_values(node)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def swap_keyword_values(node)
|
|
35
|
+
return if node.arguments.nil?
|
|
36
|
+
|
|
37
|
+
node.arguments.arguments.grep(Prism::KeywordHashNode).each do |keywords|
|
|
38
|
+
pairs = keywords.elements.grep(Prism::AssocNode)
|
|
39
|
+
pairs.each_cons(2) do |left, right|
|
|
40
|
+
emit_swap(node, left, right) unless value_text(left) == value_text(right)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Whatever separates the two pairs — the comma, a double splat, line breaks,
|
|
46
|
+
# comments — stays between them, so the call keeps its layout.
|
|
47
|
+
def emit_swap(node, left, right)
|
|
48
|
+
start_offset = left.location.start_offset
|
|
49
|
+
|
|
50
|
+
add_mutation(
|
|
51
|
+
offset: start_offset,
|
|
52
|
+
length: right.location.end_offset - start_offset,
|
|
53
|
+
replacement: "#{key_text(left)}#{value_text(right)}#{source_between(left, right)}" \
|
|
54
|
+
"#{key_text(right)}#{value_text(left)}",
|
|
55
|
+
node: node
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def source_between(left, right)
|
|
60
|
+
byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The key with everything up to its value: `x: `, `"x" => `.
|
|
64
|
+
def key_text(pair)
|
|
65
|
+
return "#{pair.key.slice} " if pair.value.is_a?(Prism::ImplicitNode)
|
|
66
|
+
|
|
67
|
+
byteslice_source(pair.location.start_offset, pair.value.location.start_offset - pair.location.start_offset)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A shorthand key stands for the local variable or method of the same name.
|
|
71
|
+
def value_text(pair)
|
|
72
|
+
value = pair.value
|
|
73
|
+
value.is_a?(Prism::ImplicitNode) ? value.value.name.to_s : value.slice
|
|
74
|
+
end
|
|
75
|
+
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,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Give a `case/in` that has no `else` an empty one.
|
|
6
|
+
#
|
|
7
|
+
# Without an else, a value no pattern matches raises NoMatchingPatternError;
|
|
8
|
+
# with an empty one the expression quietly yields nil. A survivor means no
|
|
9
|
+
# example feeds the match a value outside its patterns, so nothing asserts
|
|
10
|
+
# that such a value is rejected.
|
|
11
|
+
#
|
|
12
|
+
# The opposite edit, removing an else that is there, belongs to CaseIn.
|
|
13
|
+
class Evilution::Mutator::Operator::NoMatchingPatternElse < Evilution::Mutator::Base
|
|
14
|
+
def visit_case_match_node(node)
|
|
15
|
+
add_else(node) if node.else_clause.nil? && !exhaustive?(node)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def add_else(node)
|
|
22
|
+
offset = node.end_keyword_loc.start_offset
|
|
23
|
+
|
|
24
|
+
add_mutation(
|
|
25
|
+
offset: offset,
|
|
26
|
+
length: 0,
|
|
27
|
+
replacement: else_clause_before(offset),
|
|
28
|
+
node: node
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# An `end` on a line of its own gets the else on the line above, indented
|
|
33
|
+
# alike. Otherwise the case is written on one line and the else joins it.
|
|
34
|
+
def else_clause_before(offset)
|
|
35
|
+
line_start = line_start_byte(@file_source, offset)
|
|
36
|
+
indentation = byteslice_source(line_start, offset - line_start)
|
|
37
|
+
|
|
38
|
+
indentation.strip.empty? ? "else\n#{indentation}" : "else; "
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A bare name (`in other`, `in _`) matches every value, so an else after it
|
|
42
|
+
# could never run and adding one would change nothing. A guarded capture is
|
|
43
|
+
# not a bare name: Prism wraps it in the guard's conditional.
|
|
44
|
+
def exhaustive?(node)
|
|
45
|
+
node.conditions.any? { |clause| clause.pattern.is_a?(Prism::LocalVariableTargetNode) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Swap the numbered parameters of a block: `pairs.map { _1 - _2 }` becomes
|
|
6
|
+
# `pairs.map { _2 - _1 }`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means no example tells the two values apart, so the block could
|
|
9
|
+
# take them in either order unnoticed.
|
|
10
|
+
#
|
|
11
|
+
# Only parameters the body reads are swapped with each other, each with its
|
|
12
|
+
# neighbour. Swapping in a parameter the body never reads (`{ _1 }` to
|
|
13
|
+
# `{ _2 }`) would change how many values the block takes, which is a different
|
|
14
|
+
# mutation from reordering them.
|
|
15
|
+
class Evilution::Mutator::Operator::NumberedParameterSwap < Evilution::Mutator::Base
|
|
16
|
+
NUMBERED_NAME = /\A_[1-9]\z/
|
|
17
|
+
|
|
18
|
+
def visit_block_node(node)
|
|
19
|
+
swap_parameters(node)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_lambda_node(node)
|
|
24
|
+
swap_parameters(node)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def swap_parameters(node)
|
|
31
|
+
return unless node.parameters.is_a?(Prism::NumberedParametersNode)
|
|
32
|
+
|
|
33
|
+
reads = numbered_reads(node.body)
|
|
34
|
+
names = reads.map { |read| read.name.to_s }.uniq.sort
|
|
35
|
+
names.each_cons(2) { |left, right| emit_swap(node, reads, left, right) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Every read of a numbered parameter in the body. Blocks nested in it are
|
|
39
|
+
# searched too: a numbered parameter read there can only be this block's,
|
|
40
|
+
# since Ruby rejects a nested block that declares its own. A method defined
|
|
41
|
+
# in the body opens a new scope, whose blocks have parameters of their own.
|
|
42
|
+
def numbered_reads(node)
|
|
43
|
+
return [] if node.is_a?(Prism::DefNode)
|
|
44
|
+
return [node] if node.is_a?(Prism::LocalVariableReadNode) && node.name.to_s.match?(NUMBERED_NAME)
|
|
45
|
+
|
|
46
|
+
node.compact_child_nodes.flat_map { |child| numbered_reads(child) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The reads are scattered through the body, while a mutation replaces one
|
|
50
|
+
# contiguous range, so the range runs from the first affected read to the
|
|
51
|
+
# last and is rewritten with the two names exchanged.
|
|
52
|
+
def emit_swap(node, reads, left, right)
|
|
53
|
+
affected = reads.select { |read| [left, right].include?(read.name.to_s) }
|
|
54
|
+
.sort_by { |read| read.location.start_offset }
|
|
55
|
+
start_offset = affected.first.location.start_offset
|
|
56
|
+
end_offset = affected.last.location.end_offset
|
|
57
|
+
|
|
58
|
+
add_mutation(
|
|
59
|
+
offset: start_offset,
|
|
60
|
+
length: end_offset - start_offset,
|
|
61
|
+
replacement: exchange(affected, start_offset, end_offset, left => right, right => left),
|
|
62
|
+
node: node
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def exchange(affected, start_offset, end_offset, renames)
|
|
67
|
+
text = byteslice_source(start_offset, end_offset - start_offset)
|
|
68
|
+
|
|
69
|
+
affected.reverse_each do |read|
|
|
70
|
+
location = read.location
|
|
71
|
+
text = text.byteslice(0, location.start_offset - start_offset) +
|
|
72
|
+
renames.fetch(read.name.to_s) +
|
|
73
|
+
text.byteslice((location.end_offset - start_offset)..)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
text
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Shift a count held in a variable down by one: `n.times` becomes
|
|
6
|
+
# `(n - 1).times`, `items.first(n)` becomes `items.first(n - 1)`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means no example checks the last iteration or the last element,
|
|
9
|
+
# the classic fencepost slip. A literal count is left to IntegerLiteral, which
|
|
10
|
+
# already shifts it; this operator reaches the counts that have no literal.
|
|
11
|
+
class Evilution::Mutator::Operator::OffByOneBoundary < Evilution::Mutator::Base
|
|
12
|
+
# Methods whose receiver is the count.
|
|
13
|
+
RECEIVER_COUNT_METHODS = %i[times].freeze
|
|
14
|
+
|
|
15
|
+
# Methods whose single argument is the count or the bound.
|
|
16
|
+
ARGUMENT_COUNT_METHODS = %i[upto downto take first last drop each_slice each_cons].freeze
|
|
17
|
+
|
|
18
|
+
# Arguments that stand in the list without being a value: a splat,
|
|
19
|
+
# keywords, forwarded arguments. Subtracting from them would not parse.
|
|
20
|
+
NON_VALUE_ARGUMENT_TYPES = [
|
|
21
|
+
Prism::SplatNode, Prism::KeywordHashNode, Prism::ForwardingArgumentsNode
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def visit_call_node(node)
|
|
25
|
+
shift_count(node) unless node.safe_navigation?
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def shift_count(node)
|
|
32
|
+
count = count_of(node)
|
|
33
|
+
return if count.nil? || count.is_a?(Prism::IntegerNode)
|
|
34
|
+
|
|
35
|
+
add_mutation(
|
|
36
|
+
offset: count.location.start_offset,
|
|
37
|
+
length: count.location.length,
|
|
38
|
+
replacement: lowered(node, count),
|
|
39
|
+
node: node
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def count_of(node)
|
|
44
|
+
if RECEIVER_COUNT_METHODS.include?(node.name)
|
|
45
|
+
node.receiver if node.arguments.nil?
|
|
46
|
+
elsif ARGUMENT_COUNT_METHODS.include?(node.name)
|
|
47
|
+
single_argument(node)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def single_argument(node)
|
|
52
|
+
return nil if node.arguments.nil?
|
|
53
|
+
|
|
54
|
+
arguments = node.arguments.arguments
|
|
55
|
+
return nil unless arguments.length == 1
|
|
56
|
+
return nil if NON_VALUE_ARGUMENT_TYPES.include?(arguments.first.class)
|
|
57
|
+
|
|
58
|
+
arguments.first
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A receiver is followed by `.method`, so the subtraction is grouped; an
|
|
62
|
+
# argument stands on its own and only needs grouping when it is not a
|
|
63
|
+
# primary expression.
|
|
64
|
+
def lowered(node, count)
|
|
65
|
+
subtraction = "#{receiver_source(count)} - 1"
|
|
66
|
+
count.equal?(node.receiver) ? "(#{subtraction})" : subtraction
|
|
67
|
+
end
|
|
68
|
+
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
|
|
@@ -12,21 +12,32 @@ require_relative "../operator"
|
|
|
12
12
|
# that pass the argument are unaffected, which is what separates this from a
|
|
13
13
|
# mutation that breaks every call site.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# Optional parameters have to sit side by side, so only the ends of a run of
|
|
16
|
+
# them are mutated; a required parameter in the middle would split it in two.
|
|
17
|
+
# The first can always join the required parameters before it. The last can
|
|
18
|
+
# join those after it, unless a rest parameter follows: a required parameter
|
|
19
|
+
# cannot stand between an optional one and `*rest`. The rest of the signature
|
|
20
|
+
# stays as written.
|
|
17
21
|
# KeywordArgument owns optional keyword parameters. Blocks are left alone: a
|
|
18
22
|
# block ignores arity, so a missing argument arrives as nil rather than raising,
|
|
19
23
|
# and making its parameter required would change nothing.
|
|
20
24
|
class Evilution::Mutator::Operator::OptionalParameterToRequired < Evilution::Mutator::Base
|
|
21
25
|
def visit_def_node(node)
|
|
22
26
|
parameters = node.parameters
|
|
23
|
-
parameters.
|
|
27
|
+
mutable_optionals(parameters).each { |optional| require_parameter(optional) } if parameters
|
|
24
28
|
|
|
25
29
|
super
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
private
|
|
29
33
|
|
|
34
|
+
def mutable_optionals(parameters)
|
|
35
|
+
optionals = parameters.optionals
|
|
36
|
+
return optionals.first(1) if parameters.rest
|
|
37
|
+
|
|
38
|
+
[optionals.first, optionals.last].compact.uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
30
41
|
def require_parameter(node)
|
|
31
42
|
location = node.location
|
|
32
43
|
name_loc = node.name_loc
|
|
@@ -83,14 +83,25 @@ class Evilution::Mutator::Operator::PatternMatchingArray < Evilution::Mutator::B
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
def replace_pattern(node, parts)
|
|
86
|
+
constant = node.constant ? source_for(node.constant) : ""
|
|
87
|
+
opening, closing = delimiters(node)
|
|
88
|
+
|
|
86
89
|
add_mutation(
|
|
87
90
|
offset: node.location.start_offset,
|
|
88
91
|
length: node.location.length,
|
|
89
|
-
replacement: "
|
|
92
|
+
replacement: "#{constant}#{opening}#{parts.join(", ")}#{closing}",
|
|
90
93
|
node: node
|
|
91
94
|
)
|
|
92
95
|
end
|
|
93
96
|
|
|
97
|
+
# A pattern written without brackets (`in a, b`) has no delimiters of its
|
|
98
|
+
# own, so the mutant is given square ones.
|
|
99
|
+
def delimiters(node)
|
|
100
|
+
return ["[", "]"] if node.opening_loc.nil?
|
|
101
|
+
|
|
102
|
+
[node.opening_loc.slice, node.closing_loc.slice]
|
|
103
|
+
end
|
|
104
|
+
|
|
94
105
|
def source_for(node)
|
|
95
106
|
@file_source.byteslice(node.location.start_offset, node.location.length)
|
|
96
107
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Widen a pattern so it accepts input the original rejects:
|
|
6
|
+
#
|
|
7
|
+
# in [a, b] -> in [a, b, *] longer arrays
|
|
8
|
+
# in { age: Integer } -> in { age: _ } any value under the key
|
|
9
|
+
# in { name: String, age: Integer }
|
|
10
|
+
# -> in { name: String } hashes without the key
|
|
11
|
+
# in { kind: :a, **nil } -> in { kind: :a } hashes with other keys
|
|
12
|
+
#
|
|
13
|
+
# A survivor means no example feeds the pattern one of the shapes it is written
|
|
14
|
+
# to turn away.
|
|
15
|
+
#
|
|
16
|
+
# Elements of array and find patterns are widened by PatternMatchingArray. Here
|
|
17
|
+
# the pattern itself is opened up, and every edit keeps the variables the
|
|
18
|
+
# pattern binds, so the branch body still runs as written.
|
|
19
|
+
class Evilution::Mutator::Operator::PatternWildcardWidening < Evilution::Mutator::Base
|
|
20
|
+
WILDCARD = "_"
|
|
21
|
+
UNWIDENABLE_VALUE_TYPES = [Prism::LocalVariableTargetNode, Prism::ImplicitNode].freeze
|
|
22
|
+
|
|
23
|
+
def visit_array_pattern_node(node)
|
|
24
|
+
append_rest(node)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def visit_hash_pattern_node(node)
|
|
29
|
+
widen_pairs(node)
|
|
30
|
+
drop_closing_rest(node)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# A pattern with a rest is open already, at whichever end the rest sits, and
|
|
37
|
+
# an empty one has no element to append after.
|
|
38
|
+
def append_rest(node)
|
|
39
|
+
return unless node.rest.nil?
|
|
40
|
+
return if node.requireds.empty?
|
|
41
|
+
|
|
42
|
+
add_mutation(
|
|
43
|
+
offset: node.requireds.last.location.end_offset,
|
|
44
|
+
length: 0,
|
|
45
|
+
replacement: ", *",
|
|
46
|
+
node: node
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def widen_pairs(node)
|
|
51
|
+
node.elements.each_with_index do |pair, index|
|
|
52
|
+
next if binds_variable?(pair.value)
|
|
53
|
+
|
|
54
|
+
wildcard_value(node, pair)
|
|
55
|
+
# `in {}` matches the empty hash only, so dropping the last key would
|
|
56
|
+
# narrow the pattern rather than widen it.
|
|
57
|
+
drop_pair(node, index) if node.elements.length > 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A bare name reaching here is an underscore one, which already accepts any
|
|
62
|
+
# value; rewriting it to `_` would change nothing. A shorthand key
|
|
63
|
+
# (`_ignored:`) has no value pattern of its own: its implicit value spans the
|
|
64
|
+
# key, so writing a wildcard there would rename the key instead.
|
|
65
|
+
def wildcard_value(node, pair)
|
|
66
|
+
return if UNWIDENABLE_VALUE_TYPES.include?(pair.value.class)
|
|
67
|
+
|
|
68
|
+
location = pair.value.location
|
|
69
|
+
|
|
70
|
+
add_mutation(
|
|
71
|
+
offset: location.start_offset,
|
|
72
|
+
length: location.length,
|
|
73
|
+
replacement: WILDCARD,
|
|
74
|
+
node: node
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def drop_pair(node, index)
|
|
79
|
+
remaining = node.elements.dup
|
|
80
|
+
remaining.delete_at(index)
|
|
81
|
+
replace_members(node, remaining + [node.rest].compact)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `**nil` forbids keys the pattern does not name. On its own it leaves a
|
|
85
|
+
# pattern that matches the empty hash only, with or without it.
|
|
86
|
+
def drop_closing_rest(node)
|
|
87
|
+
return unless node.rest.is_a?(Prism::NoKeywordsParameterNode)
|
|
88
|
+
return if node.elements.empty?
|
|
89
|
+
|
|
90
|
+
replace_members(node, node.elements)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Rewrites what sits between the delimiters, so braces, parentheses and a
|
|
94
|
+
# constant in front of them stay as written.
|
|
95
|
+
def replace_members(node, members)
|
|
96
|
+
all = node.elements + [node.rest].compact
|
|
97
|
+
start_offset = all.first.location.start_offset
|
|
98
|
+
end_offset = all.last.location.end_offset
|
|
99
|
+
|
|
100
|
+
add_mutation(
|
|
101
|
+
offset: start_offset,
|
|
102
|
+
length: end_offset - start_offset,
|
|
103
|
+
replacement: members.map(&:slice).join(", "),
|
|
104
|
+
node: node
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whether the value pattern captures into a variable the branch body could
|
|
109
|
+
# read. Taking such a pair away, or widening it to a wildcard, would break
|
|
110
|
+
# the body on the missing name whatever the tests assert. An underscore name
|
|
111
|
+
# is a wildcard by convention and is not counted.
|
|
112
|
+
def binds_variable?(node)
|
|
113
|
+
return !node.name.start_with?(WILDCARD) if node.is_a?(Prism::LocalVariableTargetNode)
|
|
114
|
+
|
|
115
|
+
node.compact_child_nodes.any? { |child| binds_variable?(child) }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Drop the pin of a pattern variable: `in ^expected` becomes `in expected`.
|
|
6
|
+
#
|
|
7
|
+
# A pinned variable compares the matched value against an existing binding;
|
|
8
|
+
# without the pin the same name captures whatever is there, so the pattern
|
|
9
|
+
# matches every value. A survivor means no example feeds the pattern a value
|
|
10
|
+
# that differs from the pinned one.
|
|
11
|
+
class Evilution::Mutator::Operator::PinOperatorRemoval < Evilution::Mutator::Base
|
|
12
|
+
def initialize(**options)
|
|
13
|
+
super
|
|
14
|
+
@alternation_depth = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# An alternative pattern (`^a | ^b`) may not capture, at any depth inside it,
|
|
18
|
+
# so no pin under one has an unpinned form. Tracked here rather than left to
|
|
19
|
+
# the parse check: older Prism releases accept the capture that Ruby rejects.
|
|
20
|
+
def visit_alternation_pattern_node(node)
|
|
21
|
+
@alternation_depth += 1
|
|
22
|
+
super
|
|
23
|
+
ensure
|
|
24
|
+
@alternation_depth -= 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def visit_pinned_variable_node(node)
|
|
28
|
+
remove_pin(node) if @alternation_depth.zero?
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Other pins without an unpinned form are caught by the parse check. Only a
|
|
35
|
+
# local name can stand as a capturing pattern, so `^@expected` and
|
|
36
|
+
# `^$expected` have none, and neither does a numbered parameter (`^_1`).
|
|
37
|
+
# Those rewrites carry no signal, so they are dropped rather than reported
|
|
38
|
+
# as unparseable.
|
|
39
|
+
def remove_pin(node)
|
|
40
|
+
add_mutation(
|
|
41
|
+
offset: node.location.start_offset,
|
|
42
|
+
length: node.location.length,
|
|
43
|
+
replacement: node.variable.slice,
|
|
44
|
+
node: node,
|
|
45
|
+
skip_unparseable: true
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|