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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +62 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +80 -24
  6. data/docs/architecture.md +89 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +119 -16
  15. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  16. data/lib/evilution/ast/pattern/parser.rb +14 -15
  17. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  18. data/lib/evilution/ast/scope_declaration.rb +45 -0
  19. data/lib/evilution/ast/uncovered_code.rb +88 -0
  20. data/lib/evilution/ast/value_object_definition.rb +25 -0
  21. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  22. data/lib/evilution/baseline/report.rb +66 -0
  23. data/lib/evilution/baseline/spec_failure.rb +38 -0
  24. data/lib/evilution/baseline.rb +67 -30
  25. data/lib/evilution/cli/parser/file_args.rb +2 -1
  26. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  27. data/lib/evilution/cli.rb +3 -2
  28. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  29. data/lib/evilution/config.rb +3 -2
  30. data/lib/evilution/equivalent/detector.rb +3 -1
  31. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  32. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  33. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  34. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  35. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  36. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  38. data/lib/evilution/example_filter.rb +45 -0
  39. data/lib/evilution/hooks/registry.rb +2 -1
  40. data/lib/evilution/integration/base.rb +2 -0
  41. data/lib/evilution/integration/known_failures.rb +26 -0
  42. data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
  43. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  44. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  45. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  46. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  47. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  48. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  49. data/lib/evilution/integration/minitest.rb +45 -6
  50. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  51. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  52. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  53. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  54. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  55. data/lib/evilution/integration/rspec.rb +22 -4
  56. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  57. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  58. data/lib/evilution/integration/test_unit.rb +37 -9
  59. data/lib/evilution/isolation/fork.rb +2 -1
  60. data/lib/evilution/isolation/in_process.rb +21 -4
  61. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  62. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  63. data/lib/evilution/memory/leak_check.rb +27 -3
  64. data/lib/evilution/mutation.rb +7 -2
  65. data/lib/evilution/mutator/base.rb +49 -5
  66. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  67. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  68. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  69. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  70. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  71. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  72. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  73. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  74. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  75. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  76. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  77. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  78. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  79. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  80. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  81. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  82. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  83. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  84. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  85. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  86. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  87. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  88. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  89. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  90. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  91. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  92. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  93. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  94. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  95. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  96. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  97. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  98. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  99. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  100. data/lib/evilution/mutator/registry.rb +31 -2
  101. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  102. data/lib/evilution/process_supervisor.rb +3 -2
  103. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  104. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  105. data/lib/evilution/reporter/json/baseline.rb +15 -0
  106. data/lib/evilution/reporter/json.rb +7 -2
  107. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  108. data/lib/evilution/result/mutation_result.rb +9 -1
  109. data/lib/evilution/result/summary.rb +42 -2
  110. data/lib/evilution/runner/baseline_runner.rb +9 -4
  111. data/lib/evilution/runner/canary.rb +2 -57
  112. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  113. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  114. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  115. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  116. data/lib/evilution/runner/mutation_executor.rb +1 -1
  117. data/lib/evilution/runner/mutation_planner.rb +11 -2
  118. data/lib/evilution/runner/report_publisher.rb +3 -2
  119. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  120. data/lib/evilution/runner.rb +4 -1
  121. data/lib/evilution/subject.rb +8 -2
  122. data/lib/evilution/version.rb +1 -1
  123. data/lib/evilution.rb +28 -0
  124. data/script/memory_check +62 -16
  125. 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
- # Ruby allows a required parameter to follow an optional one, so each optional
16
- # is mutated on its own and the rest of the signature stays as written.
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.optionals.each { |optional| require_parameter(optional) } if 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: "[#{parts.join(", ")}]",
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
- def visit_regular_expression_node(node)
7
- content = node.content
8
- return super if content.empty?
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
- remove_quantifiers(node, content, content_offset)
13
- remove_anchors(node, content, content_offset)
14
- remove_character_class_ranges(node, content, content_offset)
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, content, content_offset)
22
- scan_regex_positions(content) do |kind, i|
23
- case kind
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
- def remove_anchors(node, content, content_offset)
51
- scan_regex_positions(content) do |kind, i|
52
- case kind
53
- when :backslash then try_emit_backslash_anchor(node, content, content_offset, i)
54
- when :class_open then class_skip(content, i)
55
- when :char
56
- try_emit_caret_dollar(node, content, content_offset, i)
57
- 1
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 try_emit_backslash_anchor(node, content, content_offset, i)
63
- anchor = match_backslash_anchor(content, i)
64
- return 2 if anchor.nil?
58
+ def lazy_marker_of?(token, previous)
59
+ return false if previous.nil?
65
60
 
66
- add_mutation(offset: content_offset + i, length: anchor.length, replacement: "", node: node)
67
- anchor.length
61
+ previous.last.text.start_with?("{") && token.text == "?" && previous[1] == token.start_offset
68
62
  end
69
63
 
70
- def try_emit_caret_dollar(node, content, content_offset, i)
71
- return unless %w[^ $].include?(content[i])
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
- add_mutation(offset: content_offset + i, length: 1, replacement: "", node: node)
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 scan_ranges_in_class(node, content, content_offset, class_start)
98
- first_item = skip_class_prefix(content, class_start)
99
- i = first_item
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
- while i < content.length && content[i] != "]"
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 skip_class_prefix(content, class_start)
113
- i = class_start + 1
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
- def emit_range_removal(node, content, content_offset, first_item, pos)
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