evilution 1.3.0 → 1.4.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 +62 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/README.md +80 -24
- data/docs/architecture.md +89 -9
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +39 -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/included_block.rb +27 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/parser.rb +119 -16
- 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/scope_declaration.rb +45 -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/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 +2 -0
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
- 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 +2 -1
- data/lib/evilution/isolation/in_process.rb +21 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -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 +7 -2
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -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/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- 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/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/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/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_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/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/registry.rb +31 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- 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.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.rb +46 -1
- data/lib/evilution/runner.rb +4 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- data/script/memory_check +62 -16
- metadata +83 -4
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../../ast/regexp_pattern"
|
|
5
|
+
|
|
6
|
+
# Flip a character type of a regexp to its complement: `\d` becomes `\D`,
|
|
7
|
+
# `\w` becomes `\W`, `\b` becomes `\B`, and back.
|
|
8
|
+
#
|
|
9
|
+
# The pattern keeps its shape but accepts the opposite characters, so a
|
|
10
|
+
# survivor means the suite never feeds the pattern both input it should match
|
|
11
|
+
# and input it should reject at that position.
|
|
12
|
+
#
|
|
13
|
+
# `\X` (grapheme cluster) and `\R` (line break) are not complements — `\X`
|
|
14
|
+
# matches a line break too — so they are left alone, as are Unicode
|
|
15
|
+
# properties. The pattern is read with regexp_parser, so escaped backslashes
|
|
16
|
+
# and extended-mode comments are not mistaken for character types.
|
|
17
|
+
#
|
|
18
|
+
# Unlike the structural regexp edits, a flip needs no compile check: it swaps
|
|
19
|
+
# one escape for another of the same kind and cannot touch a group or a
|
|
20
|
+
# reference. Across 37,840 flips in real code, every mutant compiled.
|
|
21
|
+
class Evilution::Mutator::Operator::RegexpCharacterTypeComplement < Evilution::Mutator::Base
|
|
22
|
+
# Complements by the scanner's token kind and name rather than by text:
|
|
23
|
+
# inside a character class `\b` scans as a backspace escape, and `\p{Digit}`
|
|
24
|
+
# shares the `digit` name under the property kind.
|
|
25
|
+
COMPLEMENTS = {
|
|
26
|
+
type: {
|
|
27
|
+
digit: "\\D", nondigit: "\\d",
|
|
28
|
+
space: "\\S", nonspace: "\\s",
|
|
29
|
+
word: "\\W", nonword: "\\w",
|
|
30
|
+
hex: "\\H", nonhex: "\\h"
|
|
31
|
+
},
|
|
32
|
+
anchor: { word_boundary: "\\B", nonword_boundary: "\\b" }
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def visit_regular_expression_node(node)
|
|
36
|
+
pattern = Evilution::AST::RegexpPattern.parse(node)
|
|
37
|
+
pattern.tokens.each { |token| flip(node, token) } if pattern
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def flip(node, token)
|
|
44
|
+
complement = COMPLEMENTS.fetch(token.type, {})[token.token]
|
|
45
|
+
return if complement.nil?
|
|
46
|
+
|
|
47
|
+
add_mutation(
|
|
48
|
+
offset: token.start_offset,
|
|
49
|
+
length: token.end_offset - token.start_offset,
|
|
50
|
+
replacement: complement,
|
|
51
|
+
node: node
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../../ast/regexp_pattern"
|
|
5
|
+
|
|
6
|
+
# Rename a named regexp group: `/(?<user>\w+)@/` becomes `/(?<_user>\w+)@/`.
|
|
7
|
+
#
|
|
8
|
+
# The pattern still matches and still captures, but `m[:user]`, `$~[:user]` or
|
|
9
|
+
# the local variable that `/(?<user>...)/ =~ s` binds no longer find it. A survivor
|
|
10
|
+
# means nothing outside the pattern reads the named capture.
|
|
11
|
+
#
|
|
12
|
+
# References inside the pattern (`\k<user>`, `\g<user>`, `\k<user+1>`) are
|
|
13
|
+
# renamed along with the group, as is every group sharing the name, so the
|
|
14
|
+
# pattern itself stays valid and only outside references break; with every
|
|
15
|
+
# occurrence renamed together it always compiles. The new name is the old one
|
|
16
|
+
# with an underscore in front, more if that name is taken. As with
|
|
17
|
+
# RegexpCaptureToPassive, a regexp passed straight to `match?` or `!~` exposes
|
|
18
|
+
# no captures and is skipped.
|
|
19
|
+
class Evilution::Mutator::Operator::RegexpNamedGroupRename < Evilution::Mutator::Base
|
|
20
|
+
CAPTURE_FREE_CALLS = %i[match? !~].freeze
|
|
21
|
+
NAMED_GROUP_TOKENS = %i[named_ab named_sq].freeze
|
|
22
|
+
|
|
23
|
+
# `(?<name>` / `(?'name'`, and `\k<name>`, `\g'name'`, `\k<name+1>`.
|
|
24
|
+
GROUP_NAME = /\A(\(\?[<'])([^>']+)([>'])\z/
|
|
25
|
+
REFERENCE_NAME = /\A(\\[kg][<'])([^>'+-]+)(.*)\z/m
|
|
26
|
+
|
|
27
|
+
def initialize(**options)
|
|
28
|
+
super
|
|
29
|
+
@capture_free = Set.new.compare_by_identity
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def visit_call_node(node)
|
|
33
|
+
if CAPTURE_FREE_CALLS.include?(node.name)
|
|
34
|
+
@capture_free << node.receiver
|
|
35
|
+
@capture_free.merge(node.arguments.arguments) if node.arguments
|
|
36
|
+
end
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def visit_regular_expression_node(node)
|
|
41
|
+
pattern = Evilution::AST::RegexpPattern.parse(node) unless @capture_free.include?(node)
|
|
42
|
+
rename_groups(node, pattern) if pattern
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def rename_groups(node, pattern)
|
|
49
|
+
occurrences = name_occurrences(pattern.tokens)
|
|
50
|
+
group_names = occurrences.filter_map { |_token, name, kind| name if kind == :group }.uniq
|
|
51
|
+
|
|
52
|
+
group_names.each do |name|
|
|
53
|
+
renamed = free_name(name, group_names)
|
|
54
|
+
edits = occurrences.select { |_token, other, _kind| other == name }
|
|
55
|
+
emit_rename(node, edits, renamed)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Every group opener and named reference, with the name it carries.
|
|
60
|
+
def name_occurrences(tokens)
|
|
61
|
+
tokens.filter_map do |token|
|
|
62
|
+
if token.type == :group && NAMED_GROUP_TOKENS.include?(token.token)
|
|
63
|
+
[token, token.text[GROUP_NAME, 2], :group]
|
|
64
|
+
elsif token.type == :backref && token.token.to_s.start_with?("name")
|
|
65
|
+
[token, token.text[REFERENCE_NAME, 2], :reference]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def free_name(name, taken)
|
|
71
|
+
candidate = "_#{name}"
|
|
72
|
+
candidate = "_#{candidate}" while taken.include?(candidate)
|
|
73
|
+
candidate
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The occurrences are spread through the pattern, while a mutation replaces
|
|
77
|
+
# one contiguous range, so the range runs from the first to the last and is
|
|
78
|
+
# rewritten with every occurrence renamed.
|
|
79
|
+
def emit_rename(node, edits, renamed)
|
|
80
|
+
start_offset = edits.first.first.start_offset
|
|
81
|
+
length = edits.last.first.end_offset - start_offset
|
|
82
|
+
|
|
83
|
+
add_mutation(
|
|
84
|
+
offset: start_offset,
|
|
85
|
+
length: length,
|
|
86
|
+
replacement: apply_renames(byteslice_source(start_offset, length), edits, start_offset, renamed),
|
|
87
|
+
node: node
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Applied last to first, so a rename never shifts the offsets of those still
|
|
92
|
+
# to come.
|
|
93
|
+
def apply_renames(text, edits, base_offset, renamed)
|
|
94
|
+
edits.reverse_each do |token, _name, kind|
|
|
95
|
+
relative = token.start_offset - base_offset
|
|
96
|
+
text = text.byteslice(0, relative) + renamed_text(token.text, kind, renamed) +
|
|
97
|
+
text.byteslice((relative + token.text.bytesize)..)
|
|
98
|
+
end
|
|
99
|
+
text
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def renamed_text(text, kind, renamed)
|
|
103
|
+
pattern = kind == :group ? GROUP_NAME : REFERENCE_NAME
|
|
104
|
+
text.sub(pattern) { "#{Regexp.last_match(1)}#{renamed}#{Regexp.last_match(3)}" }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../../ast/regexp_pattern"
|
|
5
|
+
|
|
6
|
+
# Drop the `i` or `m` option of a regexp literal: `/admin/i` becomes `/admin/`,
|
|
7
|
+
# `/a.b/m` becomes `/a.b/`.
|
|
8
|
+
#
|
|
9
|
+
# Without `i` the pattern matches only the letter case it is written in;
|
|
10
|
+
# without `m` a `.` no longer matches a newline. A survivor means no example
|
|
11
|
+
# feeds the pattern input in another case, or a newline where a `.` stands.
|
|
12
|
+
#
|
|
13
|
+
# A flag is only dropped where it changes something: `i` needs a cased letter
|
|
14
|
+
# written in the pattern (escapes such as `\d` and `\w` do not count), and `m`
|
|
15
|
+
# needs a `.` outside a character class. Other options (`x`, `o`, encodings)
|
|
16
|
+
# are left alone.
|
|
17
|
+
class Evilution::Mutator::Operator::RegexpOptionRemoval < Evilution::Mutator::Base
|
|
18
|
+
def visit_regular_expression_node(node)
|
|
19
|
+
pattern = Evilution::AST::RegexpPattern.parse(node)
|
|
20
|
+
if pattern
|
|
21
|
+
drop_option(node, "i") if node.ignore_case? && cased_letter?(pattern)
|
|
22
|
+
drop_option(node, "m") if node.multi_line? && dot?(pattern)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def cased_letter?(pattern)
|
|
31
|
+
pattern.tokens.any? do |token|
|
|
32
|
+
token.type == :literal && token.text.each_char.any? { |char| char.swapcase != char }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dot?(pattern)
|
|
37
|
+
pattern.tokens.any? { |token| token.type == :meta && token.token == :dot }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The options follow the closing delimiter: `/im`, `}mi`.
|
|
41
|
+
def drop_option(node, option)
|
|
42
|
+
closing = node.closing_loc
|
|
43
|
+
|
|
44
|
+
add_mutation(
|
|
45
|
+
offset: closing.start_offset + closing.slice.index(option),
|
|
46
|
+
length: 1,
|
|
47
|
+
replacement: "",
|
|
48
|
+
node: node
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../../ast/regexp_pattern"
|
|
5
|
+
|
|
6
|
+
# Swap a regexp quantifier between "zero or more" and "one or more": `*`
|
|
7
|
+
# becomes `+` and `+` becomes `*`, keeping a lazy (`*?`) or possessive (`*+`)
|
|
8
|
+
# quantifier lazy or possessive.
|
|
9
|
+
#
|
|
10
|
+
# Only the empty case changes: `/\A\d*\z/` accepts "" and `/\A\d+\z/` does
|
|
11
|
+
# not. A survivor means no example checks how the pattern treats an absent
|
|
12
|
+
# repetition. RegexSimplification's quantifier removal changes the many case
|
|
13
|
+
# too; this isolates the empty one.
|
|
14
|
+
#
|
|
15
|
+
# Making a recursive `\g<name>` call mandatory can make the pattern recurse
|
|
16
|
+
# forever, so each mutant is compiled first and dropped if it does not.
|
|
17
|
+
class Evilution::Mutator::Operator::RegexpQuantifierMinimumSwap < Evilution::Mutator::Base
|
|
18
|
+
SWAPS = {
|
|
19
|
+
zero_or_more: "+", zero_or_more_reluctant: "+?", zero_or_more_possessive: "++",
|
|
20
|
+
one_or_more: "*", one_or_more_reluctant: "*?", one_or_more_possessive: "*+"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def visit_regular_expression_node(node)
|
|
24
|
+
pattern = Evilution::AST::RegexpPattern.parse(node)
|
|
25
|
+
pattern.tokens.each { |token| swap(node, pattern, token) } if pattern
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def swap(node, pattern, token)
|
|
32
|
+
return unless token.type == :quantifier
|
|
33
|
+
|
|
34
|
+
replacement = SWAPS[token.token]
|
|
35
|
+
return if replacement.nil?
|
|
36
|
+
return unless pattern.compiles?(token.start_offset, token.end_offset, replacement)
|
|
37
|
+
|
|
38
|
+
add_mutation(
|
|
39
|
+
offset: token.start_offset,
|
|
40
|
+
length: token.end_offset - token.start_offset,
|
|
41
|
+
replacement: replacement,
|
|
42
|
+
node: node
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../rescue_handlers"
|
|
5
|
+
|
|
6
|
+
# Move the else clause of a rescue into the code it guards, keeping the rescue:
|
|
7
|
+
#
|
|
8
|
+
# begin begin
|
|
9
|
+
# fetch(id) fetch(id)
|
|
10
|
+
# rescue StandardError -> notify
|
|
11
|
+
# default rescue StandardError
|
|
12
|
+
# else default
|
|
13
|
+
# notify end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The else code still runs only when the body succeeded; the one thing that
|
|
17
|
+
# changes is that an error it raises is now caught by the rescue, where an
|
|
18
|
+
# else clause is not protected. A survivor means no test lets the else code
|
|
19
|
+
# fail in a way the rescue would swallow.
|
|
20
|
+
#
|
|
21
|
+
# Only a rescue with at least one broad clause — a bare `rescue`, or one
|
|
22
|
+
# naming StandardError or Exception — is mutated. Under a rescue for specific
|
|
23
|
+
# classes the moved code changes behaviour only if it raises one of them,
|
|
24
|
+
# which it rarely does, so those mutants would mostly survive without saying
|
|
25
|
+
# anything. An empty else clause and a rescue around an empty body are
|
|
26
|
+
# skipped; ensure clauses and every rescue clause stay as written.
|
|
27
|
+
class Evilution::Mutator::Operator::RescueElseConcatenation < Evilution::Mutator::Base
|
|
28
|
+
BROAD_EXCEPTIONS = %w[StandardError ::StandardError Exception ::Exception].freeze
|
|
29
|
+
|
|
30
|
+
def visit_begin_node(node)
|
|
31
|
+
else_clause = node.else_clause
|
|
32
|
+
move_else(node, else_clause) if node.statements && else_clause && else_clause.statements && broad_rescue?(node)
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def broad_rescue?(node)
|
|
39
|
+
Evilution::Mutator::RescueHandlers.new(node).clauses.any? do |clause|
|
|
40
|
+
clause.exceptions.empty? || clause.exceptions.any? { |exception| BROAD_EXCEPTIONS.include?(exception.slice) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One edit from the end of the body to the end of the else body: the else
|
|
45
|
+
# statements go first, then the rescue clauses as they were, with the
|
|
46
|
+
# whitespace (and a one-line `;`) that led into `else` trimmed off.
|
|
47
|
+
def move_else(node, else_clause)
|
|
48
|
+
body_end = node.statements.location.end_offset
|
|
49
|
+
continuation = Evilution::Mutator::RescueHandlers.continuation(@file_source, node.statements)
|
|
50
|
+
|
|
51
|
+
add_mutation(
|
|
52
|
+
offset: body_end,
|
|
53
|
+
length: else_clause.statements.location.end_offset - body_end,
|
|
54
|
+
replacement: "#{continuation}#{else_clause.statements.slice}#{rescue_clauses_text(body_end, else_clause)}",
|
|
55
|
+
node: else_clause
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def rescue_clauses_text(body_end, else_clause)
|
|
60
|
+
byteslice_source(body_end, else_clause.else_keyword_loc.start_offset - body_end).rstrip.delete_suffix(";")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../rescue_handlers"
|
|
5
|
+
|
|
6
|
+
# Run a rescue handler after the code it protects as well, keeping the rescue:
|
|
7
|
+
#
|
|
8
|
+
# begin begin
|
|
9
|
+
# fetch(id) fetch(id)
|
|
10
|
+
# rescue NotFound -> rollback
|
|
11
|
+
# rollback rescue NotFound
|
|
12
|
+
# end rollback
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# The failure path is untouched; only a successful run changes, now also
|
|
16
|
+
# doing what the handler does. A survivor means the handler is harmless when
|
|
17
|
+
# nothing failed — the tests never show it is meant for the failure only.
|
|
18
|
+
# Dropping the rescue as well would repeat RescueRemoval and hide this signal
|
|
19
|
+
# behind any test of the failure path.
|
|
20
|
+
#
|
|
21
|
+
# With several rescue clauses each handler is used in its own mutant. An empty
|
|
22
|
+
# handler adds nothing and is skipped, as is one that only produces a value
|
|
23
|
+
# (`nil`, a literal, a variable, a bare `next`): run after the body it changes
|
|
24
|
+
# nothing but the return value, which RescueHandlerPromotion already probes.
|
|
25
|
+
# Handlers that only work inside the rescue are skipped too (see
|
|
26
|
+
# Mutator::RescueHandlers). A rescue modifier sits in value position, where a
|
|
27
|
+
# statement sequence does not fit, and is left alone.
|
|
28
|
+
class Evilution::Mutator::Operator::RescueHandlerConcatenation < Evilution::Mutator::Base
|
|
29
|
+
VALUE_TYPES = [
|
|
30
|
+
Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::IntegerNode, Prism::FloatNode,
|
|
31
|
+
Prism::StringNode, Prism::SymbolNode, Prism::SelfNode, Prism::ConstantReadNode,
|
|
32
|
+
Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
def visit_begin_node(node)
|
|
36
|
+
append_handlers(node) if node.rescue_clause && node.statements
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def append_handlers(node)
|
|
43
|
+
body_end = node.statements.location.end_offset
|
|
44
|
+
separator = Evilution::Mutator::RescueHandlers.continuation(@file_source, node.statements)
|
|
45
|
+
|
|
46
|
+
Evilution::Mutator::RescueHandlers.new(node).clauses.each do |clause|
|
|
47
|
+
handler = clause.statements
|
|
48
|
+
next if handler.nil? || value_only?(handler)
|
|
49
|
+
next unless Evilution::Mutator::RescueHandlers.movable?(handler, clause.reference)
|
|
50
|
+
|
|
51
|
+
add_mutation(offset: body_end, length: 0, replacement: "#{separator}#{handler.slice}", node: clause)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def value_only?(handler)
|
|
56
|
+
handler.body.all? { |statement| value?(statement) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A method call may act, so only literals, plain reads and a bare `next` /
|
|
60
|
+
# `return` count.
|
|
61
|
+
def value?(statement)
|
|
62
|
+
case statement
|
|
63
|
+
when *VALUE_TYPES then true
|
|
64
|
+
when Prism::ArrayNode, Prism::HashNode then statement.elements.empty?
|
|
65
|
+
when Prism::NextNode, Prism::ReturnNode then statement.arguments.nil?
|
|
66
|
+
else false
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../rescue_handlers"
|
|
5
|
+
|
|
6
|
+
# Run a rescue handler instead of the code it protects:
|
|
7
|
+
# `begin; fetch(id); rescue NotFound; default; end` becomes
|
|
8
|
+
# `begin; default; end`, a method-level rescue does the same to the method
|
|
9
|
+
# body, and `fetch(id) rescue default` becomes `default`.
|
|
10
|
+
#
|
|
11
|
+
# The happy path never runs, so a survivor means the tests accept the
|
|
12
|
+
# fallback value where the real result was expected — they never check what
|
|
13
|
+
# the protected code returns.
|
|
14
|
+
#
|
|
15
|
+
# With several rescue clauses each handler is promoted in its own mutant. An
|
|
16
|
+
# else clause goes with the body, since it only runs when nothing is rescued;
|
|
17
|
+
# an ensure clause stays. An empty handler becomes `nil`, what a swallowed
|
|
18
|
+
# error returns.
|
|
19
|
+
#
|
|
20
|
+
# Handlers that only make sense inside the rescue are skipped, since promoted
|
|
21
|
+
# they fail for that reason alone; Mutator::RescueHandlers holds the rule.
|
|
22
|
+
class Evilution::Mutator::Operator::RescueHandlerPromotion < Evilution::Mutator::Base
|
|
23
|
+
def visit_begin_node(node)
|
|
24
|
+
promote_handlers(node) if node.rescue_clause && node.statements
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def visit_rescue_modifier_node(node)
|
|
29
|
+
fallback = node.rescue_expression
|
|
30
|
+
replace(node, fallback.slice, node) if Evilution::Mutator::RescueHandlers.movable?(fallback, nil)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def promote_handlers(node)
|
|
37
|
+
start_offset = node.statements.location.start_offset
|
|
38
|
+
end_offset = protected_end(node)
|
|
39
|
+
|
|
40
|
+
Evilution::Mutator::RescueHandlers.new(node).clauses.each do |clause|
|
|
41
|
+
next unless Evilution::Mutator::RescueHandlers.movable?(clause.statements, clause.reference)
|
|
42
|
+
|
|
43
|
+
handler = clause.statements ? clause.statements.slice : "nil"
|
|
44
|
+
add_mutation(offset: start_offset, length: end_offset - start_offset, replacement: handler, node: clause)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The body, every rescue clause and an else clause are replaced; an ensure
|
|
49
|
+
# clause and the closing `end` stay. A rescue clause's location runs on
|
|
50
|
+
# through the clauses after it, and an else clause's up to the keyword that
|
|
51
|
+
# follows it, so the span ends at the last clause's own content.
|
|
52
|
+
def protected_end(node)
|
|
53
|
+
else_clause = node.else_clause
|
|
54
|
+
return Evilution::Mutator::RescueHandlers.new(node).clauses.last.location.end_offset if else_clause.nil?
|
|
55
|
+
|
|
56
|
+
return else_clause.statements.location.end_offset if else_clause.statements
|
|
57
|
+
|
|
58
|
+
else_clause.else_keyword_loc.end_offset
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def replace(target, replacement, node)
|
|
62
|
+
location = target.location
|
|
63
|
+
add_mutation(offset: location.start_offset, length: location.length, replacement: replacement, node: node)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../rescue_handlers"
|
|
5
|
+
|
|
6
|
+
# Drop the `return` keyword and keep its value: `return :neg if x.negative?`
|
|
7
|
+
# becomes `:neg if x.negative?`.
|
|
8
|
+
#
|
|
9
|
+
# Control flow continues past the point where the method used to leave, so a
|
|
10
|
+
# survivor means no test notices the code after a guard clause or an early
|
|
11
|
+
# return running anyway. ReturnValueRemoval keeps the early exit and drops the
|
|
12
|
+
# value; this keeps the value and drops the exit. Several values become an
|
|
13
|
+
# array, as `return` would make them: `return a, b` becomes `[a, b]`.
|
|
14
|
+
#
|
|
15
|
+
# A return in tail position — the method's last statement, the last statement
|
|
16
|
+
# of a branch that is itself in tail position, or the tail of a lambda — is
|
|
17
|
+
# skipped: its value is the result either way. A return inside a block always
|
|
18
|
+
# leaves the method, so it is never tail position. A bare `return` has no
|
|
19
|
+
# value to keep.
|
|
20
|
+
class Evilution::Mutator::Operator::ReturnKeywordRemoval < Evilution::Mutator::Base
|
|
21
|
+
# For each node that passes a value on, the parts whose value becomes its
|
|
22
|
+
# own. With an else clause a begin body's value is not the result, the
|
|
23
|
+
# else's is; each rescue handler's value can be the result too.
|
|
24
|
+
TAIL_PARTS = {
|
|
25
|
+
Prism::StatementsNode => ->(node) { [node.body.last] },
|
|
26
|
+
Prism::ParenthesesNode => ->(node) { [node.body] },
|
|
27
|
+
Prism::ElseNode => ->(node) { [node.statements] },
|
|
28
|
+
Prism::IfNode => ->(node) { [node.statements, node.subsequent] },
|
|
29
|
+
Prism::UnlessNode => ->(node) { [node.statements, node.else_clause] },
|
|
30
|
+
Prism::CaseNode => ->(node) { [*node.conditions.map(&:statements), node.else_clause] },
|
|
31
|
+
Prism::CaseMatchNode => ->(node) { [*node.conditions.map(&:statements), node.else_clause] },
|
|
32
|
+
Prism::BeginNode => lambda { |node|
|
|
33
|
+
[node.else_clause || node.statements, *Evilution::Mutator::RescueHandlers.new(node).clauses.map(&:statements)]
|
|
34
|
+
}
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def initialize(**options)
|
|
38
|
+
super
|
|
39
|
+
@tail_returns = Set.new.compare_by_identity
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def visit_def_node(node)
|
|
43
|
+
@tail_returns.merge(tail_returns(node.body))
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def visit_lambda_node(node)
|
|
48
|
+
@tail_returns.merge(tail_returns(node.body))
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def visit_return_node(node)
|
|
53
|
+
drop_keyword(node) if node.arguments && !@tail_returns.include?(node)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def drop_keyword(node)
|
|
60
|
+
arguments = node.arguments.arguments
|
|
61
|
+
value = if arguments.length == 1 && !arguments.first.is_a?(Prism::SplatNode)
|
|
62
|
+
arguments.first.slice
|
|
63
|
+
else
|
|
64
|
+
"[#{arguments.map(&:slice).join(", ")}]"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
add_mutation(offset: node.location.start_offset, length: node.location.length, replacement: value, node: node)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The returns whose value is the value of `node` itself.
|
|
71
|
+
def tail_returns(node)
|
|
72
|
+
return [node] if node.is_a?(Prism::ReturnNode)
|
|
73
|
+
|
|
74
|
+
parts = TAIL_PARTS[node.class]
|
|
75
|
+
return [] if parts.nil?
|
|
76
|
+
|
|
77
|
+
parts.call(node).compact.flat_map { |part| tail_returns(part) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Turn a rightward pattern match into a pattern predicate: `value => [a, b]`
|
|
6
|
+
# becomes `value in [a, b]`.
|
|
7
|
+
#
|
|
8
|
+
# On a mismatch the original raises NoMatchingPatternError, while the predicate
|
|
9
|
+
# returns false and leaves the pattern's variables nil. A survivor means no
|
|
10
|
+
# example feeds the match a value it rejects, so the failure mode is untested.
|
|
11
|
+
class Evilution::Mutator::Operator::RightwardAssignment < Evilution::Mutator::Base
|
|
12
|
+
def visit_match_required_node(node)
|
|
13
|
+
replace_operator(node) unless irrefutable?(node.pattern)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def replace_operator(node)
|
|
20
|
+
location = node.operator_loc
|
|
21
|
+
|
|
22
|
+
add_mutation(
|
|
23
|
+
offset: location.start_offset,
|
|
24
|
+
length: location.length,
|
|
25
|
+
replacement: keyword_for(node),
|
|
26
|
+
node: node
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# `=>` is punctuation and may touch its neighbours (`value=>Integer`), but
|
|
31
|
+
# `in` is a word: written flush against them it would merge into a single
|
|
32
|
+
# name. A space is added on each side that has none.
|
|
33
|
+
def keyword_for(node)
|
|
34
|
+
location = node.operator_loc
|
|
35
|
+
before = node.value.location.end_offset == location.start_offset ? " " : ""
|
|
36
|
+
after = node.pattern.location.start_offset == location.end_offset ? " " : ""
|
|
37
|
+
|
|
38
|
+
"#{before}in#{after}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A bare name (`value => captured`, `value => _`) captures any value, so the
|
|
42
|
+
# match can never raise and the predicate form would behave the same.
|
|
43
|
+
def irrefutable?(pattern)
|
|
44
|
+
pattern.is_a?(Prism::LocalVariableTargetNode)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -43,6 +43,8 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
|
|
|
43
43
|
end_with?: [:start_with?],
|
|
44
44
|
ceil: [:floor],
|
|
45
45
|
floor: [:ceil],
|
|
46
|
+
round: %i[floor ceil],
|
|
47
|
+
truncate: [:floor],
|
|
46
48
|
transform_keys: [:transform_values],
|
|
47
49
|
transform_values: [:transform_keys],
|
|
48
50
|
append: [:prepend],
|
|
@@ -4,7 +4,7 @@ require_relative "../operator"
|
|
|
4
4
|
|
|
5
5
|
class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
|
|
6
6
|
def visit_splat_node(node)
|
|
7
|
-
mutate_remove_splat(node) if node.expression
|
|
7
|
+
mutate_remove_splat(node) if node.expression && !pattern_rests.include?(node)
|
|
8
8
|
|
|
9
9
|
super
|
|
10
10
|
end
|
|
@@ -14,20 +14,40 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
|
|
|
14
14
|
super
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# The rest of a pattern binds what is left over; it is not a splat in a
|
|
18
|
+
# call or a literal, so these splats are skipped. Dropping `**` from
|
|
19
|
+
# `{ key:, **opts }` is a syntax error. Dropping `*` from `[a, *rest]`
|
|
20
|
+
# parses, but it narrows the pattern to a fixed length. That mutant is
|
|
21
|
+
# deliberately not emitted, and the pattern operators keep a binding rest
|
|
22
|
+
# (`*rest`, `**opts`) as written. The rest of a multiple assignment
|
|
23
|
+
# (`a, *b = x`) is still mutated: `a, b = x` parses and changes what `b`
|
|
24
|
+
# binds.
|
|
25
|
+
def visit_hash_pattern_node(node)
|
|
26
|
+
pattern_rests.add(node.rest) if node.rest
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def visit_array_pattern_node(node)
|
|
31
|
+
pattern_rests.add(node.rest) if node.rest
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def visit_find_pattern_node(node)
|
|
36
|
+
pattern_rests.add(node.left)
|
|
37
|
+
pattern_rests.add(node.right)
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
17
41
|
# KeywordHashNode wraps call-arg kwargs + `**splat`. When an explicit
|
|
18
|
-
# `k: v` precedes a `**opts` splat in the same call,
|
|
19
|
-
# bare `opts` puts a positional after a keyword
|
|
20
|
-
# (`bar(k: v, opts)`
|
|
21
|
-
# `visit_assoc_splat_node` skips them.
|
|
22
|
-
# (`bar(**opts, k: v)`)
|
|
42
|
+
# `k: v` or another `**splat` precedes a `**opts` splat in the same call,
|
|
43
|
+
# demoting `**opts` to bare `opts` puts a positional after a keyword
|
|
44
|
+
# argument and Ruby rejects it (`bar(k: v, opts)` and `bar(**a, opts)` are
|
|
45
|
+
# syntax errors). Mark such splats so `visit_assoc_splat_node` skips them.
|
|
46
|
+
# A splat that comes first (`bar(**opts, k: v)`) is still safe —
|
|
47
|
+
# positional-before-keyword is fine.
|
|
23
48
|
def visit_keyword_hash_node(node)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if el.is_a?(Prism::AssocSplatNode) && seen_kwarg
|
|
27
|
-
kwarg_preceded_splats.add(el)
|
|
28
|
-
elsif el.is_a?(Prism::AssocNode)
|
|
29
|
-
seen_kwarg = true
|
|
30
|
-
end
|
|
49
|
+
node.elements.drop(1).each do |el|
|
|
50
|
+
kwarg_preceded_splats.add(el) if el.is_a?(Prism::AssocSplatNode)
|
|
31
51
|
end
|
|
32
52
|
|
|
33
53
|
super
|
|
@@ -37,6 +57,7 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
|
|
|
37
57
|
return super if node.value.nil?
|
|
38
58
|
return super if hash_elements.include?(node)
|
|
39
59
|
return super if kwarg_preceded_splats.include?(node)
|
|
60
|
+
return super if pattern_rests.include?(node)
|
|
40
61
|
|
|
41
62
|
mutate_remove_double_splat(node)
|
|
42
63
|
|
|
@@ -53,6 +74,10 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
|
|
|
53
74
|
@kwarg_preceded_splats ||= Set.new.compare_by_identity
|
|
54
75
|
end
|
|
55
76
|
|
|
77
|
+
def pattern_rests
|
|
78
|
+
@pattern_rests ||= Set.new.compare_by_identity
|
|
79
|
+
end
|
|
80
|
+
|
|
56
81
|
def mutate_remove_splat(node)
|
|
57
82
|
add_mutation(
|
|
58
83
|
offset: node.location.start_offset,
|