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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a rational literal (`5r`, `1.5r`) with `0r`, `1r`, its two
|
|
6
|
+
# neighbours (`value + 1r`, `value - 1r`) and `nil`.
|
|
7
|
+
class Evilution::Mutator::Operator::RationalLiteral < Evilution::Mutator::Base
|
|
8
|
+
extend Evilution::Mutator::ConstantValueSubjects
|
|
9
|
+
|
|
10
|
+
def visit_rational_node(node)
|
|
11
|
+
replacements_for(node.value).each { |value| add_mutation_with_replacement(node, literal_for(value)) }
|
|
12
|
+
add_mutation_with_replacement(node, "nil")
|
|
13
|
+
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A complex literal (`3ri`) is replaced as a whole by ComplexLiteral. Its
|
|
18
|
+
# rational part is not a literal of its own: `nili` is not a value.
|
|
19
|
+
def visit_imaginary_node(_node); end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def replacements_for(value)
|
|
24
|
+
[0r, 1r, value + 1, value - 1].uniq - [value]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A rational literal is an integer or a decimal, so its value and both
|
|
28
|
+
# neighbours always have a finite decimal form.
|
|
29
|
+
def literal_for(value)
|
|
30
|
+
return "#{value.numerator}r" if value.denominator == 1
|
|
31
|
+
|
|
32
|
+
digits = (1..).find { |count| ((10**count) % value.denominator).zero? }
|
|
33
|
+
whole, fraction = (value.abs * (10**digits)).to_i.divmod(10**digits)
|
|
34
|
+
"#{"-" if value.negative?}#{whole}.#{fraction.to_s.rjust(digits, "0")}r"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def add_mutation_with_replacement(node, replacement)
|
|
38
|
+
add_mutation(offset: node.location.start_offset, length: node.location.length, replacement:, node:)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -39,7 +39,7 @@ class Evilution::Mutator::Operator::ReduceToSum < Evilution::Mutator::Base
|
|
|
39
39
|
# The arguments besides the `+` operand — at most an initial value — when
|
|
40
40
|
# the call is a `+` reduction; nil otherwise.
|
|
41
41
|
def initial_arguments(node)
|
|
42
|
-
arguments = node
|
|
42
|
+
arguments = argument_nodes(node)
|
|
43
43
|
|
|
44
44
|
if plus_block_pass?(node.block)
|
|
45
45
|
arguments unless plus_symbol?(arguments.last)
|
|
@@ -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
|
|
@@ -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
|