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,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
|
|
@@ -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
|
|
@@ -1,164 +1,85 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../operator"
|
|
4
|
-
|
|
4
|
+
require_relative "../../ast/regexp_pattern"
|
|
5
|
+
|
|
6
|
+
# Simplify a regexp literal one piece at a time: remove a quantifier
|
|
7
|
+
# (`/a+/` -> `/a/`), remove an anchor (`/^a$/` -> `/a$/`), or drop the dash of
|
|
8
|
+
# a character-class range (`/[a-z]/` -> `/[az]/`).
|
|
9
|
+
#
|
|
10
|
+
# The pattern is read with regexp_parser, so only real quantifiers, anchors and
|
|
11
|
+
# ranges are touched: the `?` of a group opener (`(?:`, `(?<name>`, `(?=`), the
|
|
12
|
+
# inside of a `(?#...)` comment, a property's negation (`\p{^Alpha}`) and the
|
|
13
|
+
# comments of an extended pattern contain the same characters without being
|
|
14
|
+
# any of them. Each edit is made in place in the source and kept only if the
|
|
15
|
+
# pattern still compiles.
|
|
5
16
|
class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Base
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
content_offset = node.content_loc.start_offset
|
|
17
|
+
# Line and string anchors. Word boundaries and `\G` are assertions of a
|
|
18
|
+
# different kind and are left alone.
|
|
19
|
+
REMOVABLE_ANCHORS = %i[bol eol bos eos eos_ob_eol].freeze
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
def visit_regular_expression_node(node)
|
|
22
|
+
pattern = Evilution::AST::RegexpPattern.parse(node)
|
|
23
|
+
if pattern
|
|
24
|
+
remove_quantifiers(node, pattern)
|
|
25
|
+
remove_anchors(node, pattern)
|
|
26
|
+
remove_class_ranges(node, pattern)
|
|
27
|
+
end
|
|
15
28
|
|
|
16
29
|
super
|
|
17
30
|
end
|
|
18
31
|
|
|
19
32
|
private
|
|
20
33
|
|
|
21
|
-
def remove_quantifiers(node,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
when :backslash then 2
|
|
25
|
-
when :class_open then class_skip(content, i)
|
|
26
|
-
when :char then emit_quantifier_at(node, content, content_offset, i)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def emit_quantifier_at(node, content, content_offset, i)
|
|
32
|
-
match = match_quantifier(content, i)
|
|
33
|
-
return 1 if match.nil?
|
|
34
|
-
|
|
35
|
-
add_mutation(offset: content_offset + i, length: match.length, replacement: "", node: node)
|
|
36
|
-
match.length
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def match_quantifier(content, pos)
|
|
40
|
-
case content[pos]
|
|
41
|
-
when "+", "*", "?"
|
|
42
|
-
content[pos]
|
|
43
|
-
when "{"
|
|
44
|
-
if (m = content[pos..].match(/\A\{\d+(?:,\d*)?\}/))
|
|
45
|
-
m[0]
|
|
46
|
-
end
|
|
34
|
+
def remove_quantifiers(node, pattern)
|
|
35
|
+
quantifier_spans(pattern.tokens).each do |start_offset, end_offset|
|
|
36
|
+
remove_span(node, pattern, start_offset, end_offset)
|
|
47
37
|
end
|
|
48
38
|
end
|
|
49
39
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
40
|
+
# Ruby reads `{2,}?` as one lazy interval, while regexp_parser reports an
|
|
41
|
+
# interval followed by a `?` quantifier. The two are joined so the lazy
|
|
42
|
+
# marker is removed with its interval instead of becoming a quantifier of
|
|
43
|
+
# its own.
|
|
44
|
+
def quantifier_spans(tokens)
|
|
45
|
+
quantifiers = tokens.select { |token| token.type == :quantifier }
|
|
46
|
+
spans = []
|
|
47
|
+
quantifiers.each do |token|
|
|
48
|
+
previous = spans.last
|
|
49
|
+
if lazy_marker_of?(token, previous)
|
|
50
|
+
previous[1] = token.end_offset
|
|
51
|
+
else
|
|
52
|
+
spans << [token.start_offset, token.end_offset, token]
|
|
58
53
|
end
|
|
59
54
|
end
|
|
55
|
+
spans.map { |start_offset, end_offset, _token| [start_offset, end_offset] }
|
|
60
56
|
end
|
|
61
57
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
return 2 if anchor.nil?
|
|
58
|
+
def lazy_marker_of?(token, previous)
|
|
59
|
+
return false if previous.nil?
|
|
65
60
|
|
|
66
|
-
|
|
67
|
-
anchor.length
|
|
61
|
+
previous.last.text.start_with?("{") && token.text == "?" && previous[1] == token.start_offset
|
|
68
62
|
end
|
|
69
63
|
|
|
70
|
-
def
|
|
71
|
-
|
|
64
|
+
def remove_anchors(node, pattern)
|
|
65
|
+
pattern.tokens.each do |token|
|
|
66
|
+
next unless token.type == :anchor && REMOVABLE_ANCHORS.include?(token.token)
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def match_backslash_anchor(content, pos)
|
|
77
|
-
return nil unless content[pos] == "\\"
|
|
78
|
-
|
|
79
|
-
two_char = content[pos, 2]
|
|
80
|
-
return two_char if %w[\\A \\z \\Z].include?(two_char)
|
|
81
|
-
|
|
82
|
-
nil
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def remove_character_class_ranges(node, content, content_offset)
|
|
86
|
-
scan_regex_positions(content) do |kind, i|
|
|
87
|
-
case kind
|
|
88
|
-
when :backslash then 2
|
|
89
|
-
when :class_open
|
|
90
|
-
scan_ranges_in_class(node, content, content_offset, i)
|
|
91
|
-
class_skip(content, i)
|
|
92
|
-
when :char then 1
|
|
93
|
-
end
|
|
68
|
+
remove_span(node, pattern, token.start_offset, token.end_offset)
|
|
94
69
|
end
|
|
95
70
|
end
|
|
96
71
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
72
|
+
def remove_class_ranges(node, pattern)
|
|
73
|
+
pattern.tokens.each do |token|
|
|
74
|
+
next unless token.type == :set && token.token == :range
|
|
100
75
|
|
|
101
|
-
|
|
102
|
-
if content[i] == "\\"
|
|
103
|
-
i += 2
|
|
104
|
-
next
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
emit_range_removal(node, content, content_offset, first_item, i) if content[i] == "-"
|
|
108
|
-
i += 1
|
|
76
|
+
remove_span(node, pattern, token.start_offset, token.end_offset)
|
|
109
77
|
end
|
|
110
78
|
end
|
|
111
79
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
i += 1 if i < content.length && content[i] == "^"
|
|
115
|
-
i += 1 if i < content.length && content[i] == "]"
|
|
116
|
-
i
|
|
117
|
-
end
|
|
80
|
+
def remove_span(node, pattern, start_offset, end_offset)
|
|
81
|
+
return unless pattern.compiles?(start_offset, end_offset, "")
|
|
118
82
|
|
|
119
|
-
|
|
120
|
-
return unless pos > first_item && pos + 1 < content.length && content[pos + 1] != "]"
|
|
121
|
-
|
|
122
|
-
add_mutation(
|
|
123
|
-
offset: content_offset + pos,
|
|
124
|
-
length: 1,
|
|
125
|
-
replacement: "",
|
|
126
|
-
node: node
|
|
127
|
-
)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# Walks `content` yielding (kind, position) for each significant token:
|
|
131
|
-
# :backslash for an escape sequence, :class_open for `[`, :char for any
|
|
132
|
-
# other byte. The block returns the number of characters to advance from
|
|
133
|
-
# `position` — callers decide how to handle each case (skip, emit a
|
|
134
|
-
# mutation, descend into a character class, etc.).
|
|
135
|
-
def scan_regex_positions(content)
|
|
136
|
-
i = 0
|
|
137
|
-
while i < content.length
|
|
138
|
-
advance = case content[i]
|
|
139
|
-
when "\\" then yield(:backslash, i)
|
|
140
|
-
when "[" then yield(:class_open, i)
|
|
141
|
-
else yield(:char, i)
|
|
142
|
-
end
|
|
143
|
-
i += advance
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def class_skip(content, pos)
|
|
148
|
-
skip_character_class(content, pos) - pos
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def skip_character_class(content, pos)
|
|
152
|
-
scan_to_class_close(content, skip_class_prefix(content, pos))
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def scan_to_class_close(content, start)
|
|
156
|
-
i = start
|
|
157
|
-
while i < content.length
|
|
158
|
-
return i + 1 if content[i] == "]"
|
|
159
|
-
|
|
160
|
-
i += content[i] == "\\" ? 2 : 1
|
|
161
|
-
end
|
|
162
|
-
i
|
|
83
|
+
add_mutation(offset: start_offset, length: end_offset - start_offset, replacement: "", node: node)
|
|
163
84
|
end
|
|
164
85
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../../ast/regexp_pattern"
|
|
5
|
+
|
|
6
|
+
# Delete one branch of a regexp alternation at a time: `/cat|dog/` becomes
|
|
7
|
+
# `/dog/` and `/cat/`.
|
|
8
|
+
#
|
|
9
|
+
# A survivor means no example feeds the pattern input that only that branch
|
|
10
|
+
# accepts, so the branch could go without any test noticing.
|
|
11
|
+
#
|
|
12
|
+
# Alternations of more than ten branches are skipped: those are lookup tables
|
|
13
|
+
# (generated keyword or emoji lists, for instance), where a deletion per entry
|
|
14
|
+
# floods the report with survivors nobody acts on. Deleting a branch can remove
|
|
15
|
+
# a group that a backreference or a `\g<name>` call elsewhere in the pattern
|
|
16
|
+
# needs, so each mutant is compiled first and dropped if it no longer does.
|
|
17
|
+
class Evilution::Mutator::Operator::RegexpAlternationBranchDeletion < Evilution::Mutator::Base
|
|
18
|
+
MAX_BRANCHES = 10
|
|
19
|
+
|
|
20
|
+
def visit_regular_expression_node(node)
|
|
21
|
+
pattern = Evilution::AST::RegexpPattern.parse(node)
|
|
22
|
+
if pattern
|
|
23
|
+
pattern.each_expression do |expression, *|
|
|
24
|
+
delete_branches(node, pattern, expression) if expression.is_a?(Regexp::Expression::Alternation)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def delete_branches(node, pattern, alternation)
|
|
34
|
+
branches = alternation.expressions
|
|
35
|
+
return if branches.length > MAX_BRANCHES
|
|
36
|
+
|
|
37
|
+
branches.each_index do |index|
|
|
38
|
+
start_offset, end_offset = deletion_span(pattern, branches, index)
|
|
39
|
+
next unless pattern.compiles?(start_offset, end_offset, "")
|
|
40
|
+
|
|
41
|
+
add_mutation(offset: start_offset, length: end_offset - start_offset, replacement: "", node: node)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A branch goes with one neighbouring `|`: the one after it for the first
|
|
46
|
+
# branch, the one before it for every other.
|
|
47
|
+
def deletion_span(pattern, branches, index)
|
|
48
|
+
if index.zero?
|
|
49
|
+
[pattern.offsets(branches[0]).first, pattern.offsets(branches[1]).first]
|
|
50
|
+
else
|
|
51
|
+
[pattern.offsets(branches[index - 1]).last, pattern.offsets(branches[index]).last]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../../ast/regexp_pattern"
|
|
5
|
+
|
|
6
|
+
# Promote a line anchor to the matching string anchor: `^` becomes `\A`, `$`
|
|
7
|
+
# becomes `\z`, and `\Z`, which still allows a trailing newline, becomes `\z`.
|
|
8
|
+
#
|
|
9
|
+
# A line anchor also matches around every newline in the input, so `/^admin$/`
|
|
10
|
+
# accepts "evil\nadmin" while `/\Aadmin\z/` does not — the classic multiline
|
|
11
|
+
# injection gap. A survivor means no example feeds the pattern multi-line
|
|
12
|
+
# input, or the anchor should have been a string anchor all along.
|
|
13
|
+
#
|
|
14
|
+
# Unlike RegexSimplification, which removes anchors, this keeps the pattern
|
|
15
|
+
# anchored and only narrows what the anchor accepts. The pattern is read with
|
|
16
|
+
# regexp_parser, so `^` and `$` inside a character class, escaped, or in an
|
|
17
|
+
# extended-mode comment are left alone. Swapping one anchor for another cannot
|
|
18
|
+
# touch a group or a reference, so no compile check is needed.
|
|
19
|
+
class Evilution::Mutator::Operator::RegexpAnchorPromotion < Evilution::Mutator::Base
|
|
20
|
+
PROMOTIONS = { bol: "\\A", eol: "\\z", eos_ob_eol: "\\z" }.freeze
|
|
21
|
+
|
|
22
|
+
def visit_regular_expression_node(node)
|
|
23
|
+
pattern = Evilution::AST::RegexpPattern.parse(node)
|
|
24
|
+
pattern.tokens.each { |token| promote(node, token) } if pattern
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def promote(node, token)
|
|
31
|
+
return unless token.type == :anchor
|
|
32
|
+
|
|
33
|
+
promotion = PROMOTIONS[token.token]
|
|
34
|
+
return if promotion.nil?
|
|
35
|
+
|
|
36
|
+
add_mutation(
|
|
37
|
+
offset: token.start_offset,
|
|
38
|
+
length: token.end_offset - token.start_offset,
|
|
39
|
+
replacement: promotion,
|
|
40
|
+
node: node
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "../../ast/regexp_pattern"
|
|
5
|
+
|
|
6
|
+
# Turn a regexp capture group into a passive group: `/id=(\d+)/` becomes
|
|
7
|
+
# `/id=(?:\d+)/`.
|
|
8
|
+
#
|
|
9
|
+
# The pattern matches the same text but no longer captures, so `$1`, `m[1]`,
|
|
10
|
+
# a `scan` result or a `\1` in a replacement loses its value. A survivor means
|
|
11
|
+
# nothing downstream reads the capture.
|
|
12
|
+
#
|
|
13
|
+
# Only mutants that can change something are emitted:
|
|
14
|
+
# - a regexp passed straight to `match?` or `!~` never exposes its captures;
|
|
15
|
+
# - with a named group present Ruby keeps only named captures, so a plain
|
|
16
|
+
# group captures nothing to begin with;
|
|
17
|
+
# - a numbered reference inside the pattern (`\2`, `\k<1>`, `\g<1>`) would be
|
|
18
|
+
# renumbered by the passive group and quietly point elsewhere, which tests
|
|
19
|
+
# the pattern rather than whether the capture is used.
|
|
20
|
+
#
|
|
21
|
+
# With named groups and numbered references ruled out, nothing else in the
|
|
22
|
+
# pattern can refer to a plain group, so turning it passive always compiles
|
|
23
|
+
# and needs no check.
|
|
24
|
+
class Evilution::Mutator::Operator::RegexpCaptureToPassive < Evilution::Mutator::Base
|
|
25
|
+
CAPTURE_FREE_CALLS = %i[match? !~].freeze
|
|
26
|
+
NAMED_GROUP_TOKENS = %i[named_ab named_sq].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(**options)
|
|
29
|
+
super
|
|
30
|
+
@capture_free = Set.new.compare_by_identity
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def visit_call_node(node)
|
|
34
|
+
if CAPTURE_FREE_CALLS.include?(node.name)
|
|
35
|
+
@capture_free << node.receiver
|
|
36
|
+
@capture_free.merge(node.arguments.arguments) if node.arguments
|
|
37
|
+
end
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def visit_regular_expression_node(node)
|
|
42
|
+
pattern = Evilution::AST::RegexpPattern.parse(node) unless @capture_free.include?(node)
|
|
43
|
+
pattern.tokens.each { |token| make_passive(node, token) } if pattern && plain_captures_matter?(pattern)
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def plain_captures_matter?(pattern)
|
|
50
|
+
pattern.tokens.none? do |token|
|
|
51
|
+
(token.type == :group && NAMED_GROUP_TOKENS.include?(token.token)) ||
|
|
52
|
+
(token.type == :backref && token.token.to_s.start_with?("number"))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def make_passive(node, token)
|
|
57
|
+
return unless token.type == :group && token.token == :capture
|
|
58
|
+
|
|
59
|
+
add_mutation(offset: token.start_offset, length: token.end_offset - token.start_offset, replacement: "(?:", node: node)
|
|
60
|
+
end
|
|
61
|
+
end
|