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