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.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +132 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +136 -38
  6. data/docs/architecture.md +135 -11
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +121 -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/class_body.rb +101 -0
  13. data/lib/evilution/ast/included_block.rb +41 -0
  14. data/lib/evilution/ast/literal_callable.rb +21 -0
  15. data/lib/evilution/ast/never_nil.rb +36 -0
  16. data/lib/evilution/ast/parser.rb +187 -21
  17. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  18. data/lib/evilution/ast/pattern/parser.rb +14 -15
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/round_half_mode.rb +38 -0
  21. data/lib/evilution/ast/scope_declaration.rb +45 -0
  22. data/lib/evilution/ast/source_surgeon.rb +2 -3
  23. data/lib/evilution/ast/syntax_check.rb +56 -0
  24. data/lib/evilution/ast/uncovered_code.rb +88 -0
  25. data/lib/evilution/ast/value_object_definition.rb +25 -0
  26. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  27. data/lib/evilution/baseline/report.rb +66 -0
  28. data/lib/evilution/baseline/spec_failure.rb +38 -0
  29. data/lib/evilution/baseline.rb +67 -30
  30. data/lib/evilution/cli/exit_guard.rb +26 -4
  31. data/lib/evilution/cli/parser/file_args.rb +2 -1
  32. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  33. data/lib/evilution/cli.rb +3 -2
  34. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  35. data/lib/evilution/config.rb +3 -2
  36. data/lib/evilution/equivalent/detector.rb +3 -1
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  38. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  39. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  40. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  41. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  42. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  43. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  44. data/lib/evilution/example_filter.rb +45 -0
  45. data/lib/evilution/hooks/registry.rb +2 -1
  46. data/lib/evilution/integration/base.rb +11 -1
  47. data/lib/evilution/integration/known_failures.rb +26 -0
  48. data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
  49. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  50. data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
  51. data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
  52. data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
  53. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  54. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  55. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  56. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  57. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  58. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  59. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  60. data/lib/evilution/integration/minitest.rb +45 -6
  61. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  62. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  63. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  64. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  65. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  66. data/lib/evilution/integration/rspec.rb +22 -4
  67. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  68. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  69. data/lib/evilution/integration/test_unit.rb +37 -9
  70. data/lib/evilution/isolation/fork.rb +84 -11
  71. data/lib/evilution/isolation/in_process.rb +100 -8
  72. data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
  73. data/lib/evilution/mcp/info_tool.rb +3 -2
  74. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  75. data/lib/evilution/memory/leak_check.rb +27 -3
  76. data/lib/evilution/mutation.rb +21 -9
  77. data/lib/evilution/mutator/base.rb +69 -5
  78. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  79. data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
  80. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  81. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  82. data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
  83. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  84. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  85. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  86. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  87. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  88. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  89. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  90. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  91. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  92. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  93. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  94. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  95. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  96. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  97. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  98. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  99. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  100. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  101. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  102. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  103. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  104. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  105. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  106. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  107. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  108. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  109. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  110. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  111. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  112. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  113. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  114. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  115. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  116. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  117. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  118. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  119. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  120. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  121. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  122. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  123. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  124. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  125. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  126. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  127. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  128. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  129. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  130. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  131. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  132. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  133. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  134. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  135. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  136. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  137. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  138. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  139. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  140. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  141. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  142. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  143. data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
  144. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  145. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  146. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  147. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  148. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  149. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  150. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  151. data/lib/evilution/mutator/primitives.rb +24 -2
  152. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  153. data/lib/evilution/mutator/registry.rb +58 -2
  154. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  155. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  156. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  157. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  158. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  159. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  160. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  161. data/lib/evilution/parallel/work_queue.rb +15 -9
  162. data/lib/evilution/process_supervisor.rb +3 -2
  163. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  164. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  165. data/lib/evilution/reporter/json/baseline.rb +15 -0
  166. data/lib/evilution/reporter/json.rb +7 -2
  167. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  168. data/lib/evilution/result/mutation_result.rb +9 -1
  169. data/lib/evilution/result/summary.rb +42 -2
  170. data/lib/evilution/runner/baseline_runner.rb +9 -4
  171. data/lib/evilution/runner/canary.rb +2 -57
  172. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  173. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  174. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  175. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  176. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  177. data/lib/evilution/runner/mutation_executor.rb +1 -1
  178. data/lib/evilution/runner/mutation_planner.rb +11 -2
  179. data/lib/evilution/runner/report_publisher.rb +3 -2
  180. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  181. data/lib/evilution/runner/subject_pipeline.rb +57 -43
  182. data/lib/evilution/runner.rb +5 -2
  183. data/lib/evilution/subject.rb +25 -2
  184. data/lib/evilution/version.rb +1 -1
  185. data/lib/evilution.rb +90 -0
  186. data/script/memory_check +62 -16
  187. 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.arguments ? node.arguments.arguments : []
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
- 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
@@ -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