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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swallow the error of a statement that raises by convention:
6
+ # `record.save!` becomes `record.save! rescue nil`.
7
+ #
8
+ # The mutant behaves like the original until the statement fails; then it
9
+ # carries on with nil instead of raising. A survivor means no example makes
10
+ # that statement fail and checks the error comes out. It is the inverse of
11
+ # RescueRemoval: handling is added where there is none.
12
+ #
13
+ # Only calls whose failure is part of their contract are reached: bang
14
+ # methods, `fetch`, and the conversion functions `Integer`, `Float` and
15
+ # `Rational`. Wrapping every statement would mostly report that no test makes
16
+ # a log line raise. Statements already covered by a rescue are left alone, as
17
+ # is `raise`, whose swallowed form behaves as deleting the statement.
18
+ class Evilution::Mutator::Operator::ExceptionSwallow < Evilution::Mutator::Base
19
+ CONVERSION_FUNCTIONS = %i[Integer Float Rational].freeze
20
+
21
+ # A bang method name, as opposed to the `!` and `!=` operators.
22
+ BANG_METHOD = /\w!\z/
23
+
24
+ # Bangs that do not raise: Ruby's in-place methods mark a change to the
25
+ # receiver, and exit! ends the process without raising.
26
+ NON_RAISING_BANGS = %i[
27
+ uniq! sort! sort_by! select! filter! reject! map! collect! compact! flatten!
28
+ shuffle! reverse! rotate! slice! merge! transform_keys! transform_values!
29
+ gsub! sub! strip! lstrip! rstrip! chomp! chop! squeeze! tr! tr_s! delete!
30
+ downcase! upcase! capitalize! swapcase! unicode_normalize! scrub! encode!
31
+ exit!
32
+ ].freeze
33
+
34
+ # Assignments whose value is the statement's call.
35
+ WRITE_TYPES = [
36
+ Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode,
37
+ Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode
38
+ ].freeze
39
+
40
+ def visit_statements_node(node)
41
+ node.body.each { |statement| swallow(statement) }
42
+ super
43
+ end
44
+
45
+ # Everything under a `begin` (or a `def` or block body) with a rescue clause
46
+ # is already handled there, so the visitor does not descend into it.
47
+ def visit_begin_node(node)
48
+ super if node.rescue_clause.nil?
49
+ end
50
+
51
+ private
52
+
53
+ def swallow(statement)
54
+ call = statement_call(statement)
55
+ return unless call && raising?(call)
56
+
57
+ add_mutation(
58
+ offset: statement.location.end_offset,
59
+ length: 0,
60
+ replacement: " rescue nil",
61
+ node: call
62
+ )
63
+ end
64
+
65
+ def statement_call(statement)
66
+ statement = statement.value if WRITE_TYPES.include?(statement.class)
67
+ statement if statement.is_a?(Prism::CallNode)
68
+ end
69
+
70
+ def raising?(call)
71
+ name = call.name
72
+ return !NON_RAISING_BANGS.include?(name) if name.to_s.match?(BANG_METHOD)
73
+ return raising_fetch?(call) if name == :fetch
74
+
75
+ call.receiver.nil? && CONVERSION_FUNCTIONS.include?(name)
76
+ end
77
+
78
+ # With a default value or a block, fetch returns that instead of raising.
79
+ def raising_fetch?(call)
80
+ return false if call.receiver.nil? || call.block
81
+
82
+ call.arguments.nil? || call.arguments.arguments.length == 1
83
+ end
84
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Loosen the conversion specifiers of a literal format string, one at a time:
6
+ #
7
+ # format("%05d", n) -> format("%d", n) flags and width dropped
8
+ # sprintf("%.2f", x) -> sprintf("%f", x) precision dropped
9
+ # -> sprintf("%s", x) rendered as a plain string
10
+ #
11
+ # A survivor means the output is produced but its shape — padding, decimals,
12
+ # number base — is never asserted.
13
+ #
14
+ # Reaches the format string of receiverless `format`, `sprintf` and `printf`
15
+ # and the string receiver of `"..." % args`, when it is a plain literal. Integer
16
+ # conversions (`%d`, `%i`, `%u`) are not turned into `%s`: for an integer
17
+ # argument both print the same digits.
18
+ class Evilution::Mutator::Operator::FormatSpecifierSwap < Evilution::Mutator::Base
19
+ FORMAT_METHODS = %i[format sprintf printf].freeze
20
+
21
+ # `%%` and `%{name}` are matched so they are not mistaken for the start of
22
+ # a specifier; neither is mutated.
23
+ SPECIFIER = /
24
+ %(?:
25
+ %
26
+ | \{\w+\}
27
+ | (?<name><\w+>)?(?<flags>[-+\ 0\#]*)(?<width>\d+|\*)?(?:\.(?<precision>\d+|\*))?(?<type>[a-zA-Z])
28
+ )
29
+ /x
30
+
31
+ # Conversions whose output differs from `%s` for the values they take.
32
+ STRINGIFIABLE_TYPES = %w[f e E g G a A x X o b B].freeze
33
+
34
+ def visit_call_node(node)
35
+ format_string = format_string_of(node)
36
+ mutate_specifiers(node, format_string) if format_string
37
+ super
38
+ end
39
+
40
+ private
41
+
42
+ def format_string_of(node)
43
+ candidate =
44
+ if node.receiver.nil? && FORMAT_METHODS.include?(node.name)
45
+ first_argument(node)
46
+ elsif node.name == :%
47
+ node.receiver
48
+ end
49
+
50
+ candidate if plain_literal?(candidate)
51
+ end
52
+
53
+ def first_argument(node)
54
+ node.arguments.arguments.first if node.arguments
55
+ end
56
+
57
+ # A heredoc's content sits after the line that opens it, so edits inside
58
+ # it are left alone along with interpolated strings. Only `%w[]` elements
59
+ # lack an opening delimiter, and those never stand here on their own.
60
+ def plain_literal?(node)
61
+ node.is_a?(Prism::StringNode) && !node.opening_loc.slice.start_with?("<<")
62
+ end
63
+
64
+ def mutate_specifiers(node, format_string)
65
+ specifiers(format_string).each do |match, offset|
66
+ variants(match).each do |replacement|
67
+ add_mutation(offset: offset, length: match[0].bytesize, replacement: replacement, node: node)
68
+ end
69
+ end
70
+ end
71
+
72
+ # Each conversion specifier with its offset in the file. The scan runs over
73
+ # the source text between the quotes, so offsets line up with the file even
74
+ # when the string holds escapes.
75
+ def specifiers(format_string)
76
+ content = format_string.content_loc
77
+ text = byteslice_source(content.start_offset, content.length)
78
+
79
+ text.to_enum(:scan, SPECIFIER).filter_map do
80
+ match = Regexp.last_match
81
+ [match, content.start_offset + match.byteoffset(0).first] unless match[:type].nil?
82
+ end
83
+ end
84
+
85
+ # A `*` width or precision consumes an argument of its own, so dropping it
86
+ # would shift every argument after it.
87
+ def variants(match)
88
+ return [] if [match[:width], match[:precision]].include?("*")
89
+
90
+ name = match[:name]
91
+ replacements = []
92
+ replacements << "%#{name}#{match[:type]}" if modifiers?(match)
93
+ replacements << "%#{name}s" if STRINGIFIABLE_TYPES.include?(match[:type])
94
+ replacements
95
+ end
96
+
97
+ def modifiers?(match)
98
+ !match[:flags].empty? || !match[:width].nil? || !match[:precision].nil?
99
+ end
100
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Stop forwarding one part of the arguments a method passes through unnamed:
6
+ # `target.call(*, **, &)` becomes `target.call(**, &)`, and so on for each part.
7
+ #
8
+ # A survivor means that part never reaches an assertion — nothing checks that
9
+ # the positional arguments, the keywords or the block arrive at the other end.
10
+ #
11
+ # Two spellings are handled. Anonymous `*` and `**` are dropped from the
12
+ # argument list they are written in. A `...` cannot be split where it stands,
13
+ # because `*`, `**` and `&` may not be written under a `...` signature, so the
14
+ # signature is spelled out as `(*, **, &)` along with every use, and one use
15
+ # loses one part: `def f(...) = g(...)` becomes `def f(*, **, &) = g(**, &)`.
16
+ #
17
+ # Named splats are left to SplatOperator and KeywordArgument, and an anonymous
18
+ # `&` written on its own to BlockPassRemoval.
19
+ class Evilution::Mutator::Operator::ForwardedArgumentDrop < Evilution::Mutator::Base
20
+ PARTS = ["*", "**", "&"].freeze
21
+ ALL_PARTS = PARTS.join(", ").freeze
22
+
23
+ def visit_def_node(node)
24
+ drop_forwarded_parts(node)
25
+ super
26
+ end
27
+
28
+ def visit_call_node(node)
29
+ drop_anonymous_arguments(node)
30
+ super
31
+ end
32
+
33
+ def visit_super_node(node)
34
+ drop_anonymous_arguments(node)
35
+ super
36
+ end
37
+
38
+ def visit_yield_node(node)
39
+ drop_anonymous_arguments(node)
40
+ super
41
+ end
42
+
43
+ private
44
+
45
+ def drop_anonymous_arguments(node)
46
+ return if node.arguments.nil?
47
+
48
+ items = argument_items(node.arguments)
49
+ # Dropping the only argument leaves an empty list, which is the mutant
50
+ # ArgumentListRemoval already emits.
51
+ return if items.length < 2
52
+
53
+ items.each_index do |index|
54
+ emit_argument_drop(node, items, index) if anonymous?(items[index])
55
+ end
56
+ end
57
+
58
+ # Keywords sit together in one KeywordHashNode, `**` among them; they are
59
+ # listed individually so each can be dropped on its own.
60
+ def argument_items(arguments)
61
+ arguments.arguments.flat_map do |argument|
62
+ argument.is_a?(Prism::KeywordHashNode) ? argument.elements : [argument]
63
+ end
64
+ end
65
+
66
+ def anonymous?(item)
67
+ case item
68
+ when Prism::SplatNode then item.expression.nil?
69
+ when Prism::AssocSplatNode then item.value.nil?
70
+ else false
71
+ end
72
+ end
73
+
74
+ def emit_argument_drop(node, items, index)
75
+ remaining = items.map(&:slice)
76
+ remaining.delete_at(index)
77
+ location = node.arguments.location
78
+
79
+ add_mutation(
80
+ offset: location.start_offset,
81
+ length: location.length,
82
+ replacement: remaining.join(", "),
83
+ node: node
84
+ )
85
+ end
86
+
87
+ def drop_forwarded_parts(node)
88
+ return if node.body.nil?
89
+
90
+ uses = forwarding_uses(node.body)
91
+ uses.each do |target|
92
+ PARTS.each { |part| emit_part_drop(node, uses, target, part) }
93
+ end
94
+ end
95
+
96
+ # Every `...` the method forwards. A method defined in the body is not
97
+ # searched: its `...` forwards that method's own arguments.
98
+ def forwarding_uses(node)
99
+ return [] if node.is_a?(Prism::DefNode)
100
+ return [node] if node.is_a?(Prism::ForwardingArgumentsNode)
101
+
102
+ node.compact_child_nodes.flat_map { |child| forwarding_uses(child) }
103
+ end
104
+
105
+ # A `...` can only be forwarded by the method that declares it, so a use
106
+ # found in the body means the signature ends in the matching parameter.
107
+ def emit_part_drop(node, uses, target, part)
108
+ edits = uses.map do |use|
109
+ [use.location, use.equal?(target) ? (PARTS - [part]).join(", ") : ALL_PARTS]
110
+ end
111
+ edits << [node.parameters.keyword_rest.location, ALL_PARTS]
112
+
113
+ emit_rewrite(node, edits.sort_by { |location, _text| location.start_offset })
114
+ end
115
+
116
+ # The signature and the uses are apart in the source, while a mutation
117
+ # replaces one contiguous range, so the range runs from the first edit to
118
+ # the last and is rewritten with every edit applied.
119
+ def emit_rewrite(node, edits)
120
+ start_offset = edits.first.first.start_offset
121
+ length = edits.last.first.end_offset - start_offset
122
+
123
+ add_mutation(
124
+ offset: start_offset,
125
+ length: length,
126
+ replacement: apply_edits(byteslice_source(start_offset, length), edits, start_offset),
127
+ node: node
128
+ )
129
+ end
130
+
131
+ # Applied last to first, so an edit never shifts the offsets of those still
132
+ # to come.
133
+ def apply_edits(text, edits, base_offset)
134
+ edits.reverse_each do |location, replacement|
135
+ text = text.byteslice(0, location.start_offset - base_offset) +
136
+ replacement +
137
+ text.byteslice((location.end_offset - base_offset)..)
138
+ end
139
+
140
+ text
141
+ end
142
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Turn a division into `fdiv`: `a / b` becomes `a.fdiv(b)`.
6
+ #
7
+ # Between integers `/` drops the remainder while `fdiv` keeps it as a float,
8
+ # and for exact types such as Rational or BigDecimal `fdiv` falls back to
9
+ # Float. A survivor means no example divides values that leave a remainder,
10
+ # or none checks what happens to it.
11
+ #
12
+ # A float literal on either side is skipped: float division and `fdiv` give
13
+ # the same answer, so the mutant would change nothing.
14
+ class Evilution::Mutator::Operator::IntegerDivisionToFdiv < Evilution::Mutator::Base
15
+ # Receivers that `.fdiv` can follow directly. Anything else is an operator
16
+ # expression, which `.fdiv` would bind into, so it is grouped first.
17
+ SIMPLE_RECEIVER_TYPES = [
18
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
19
+ Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode,
20
+ Prism::IntegerNode, Prism::ParenthesesNode, Prism::SelfNode
21
+ ].freeze
22
+
23
+ # A method name, as opposed to an operator such as `*` or `-@`.
24
+ METHOD_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
25
+
26
+ def visit_call_node(node)
27
+ replace_division(node) if division?(node)
28
+ super
29
+ end
30
+
31
+ private
32
+
33
+ # The explicit form (`a./(b)`) is left alone: it is rare, and rewriting it
34
+ # gains nothing over the operator form. The operator form always has a
35
+ # receiver and exactly one divisor.
36
+ def division?(node)
37
+ return false unless node.name == :/ && node.call_operator_loc.nil?
38
+
39
+ [node.receiver, *node.arguments.arguments].none?(Prism::FloatNode)
40
+ end
41
+
42
+ def replace_division(node)
43
+ divisor = node.arguments.arguments.first
44
+
45
+ add_mutation(
46
+ offset: node.location.start_offset,
47
+ length: node.location.length,
48
+ replacement: "#{receiver_text(node.receiver)}.fdiv(#{divisor.slice})",
49
+ node: node
50
+ )
51
+ end
52
+
53
+ def receiver_text(receiver)
54
+ simple_receiver?(receiver) ? receiver.slice : "(#{receiver.slice})"
55
+ end
56
+
57
+ def simple_receiver?(receiver)
58
+ return true if SIMPLE_RECEIVER_TYPES.include?(receiver.class)
59
+
60
+ receiver.is_a?(Prism::CallNode) && receiver.name.to_s.match?(METHOD_NAME)
61
+ end
62
+ end
@@ -8,7 +8,7 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
8
8
  if params
9
9
  mutate_optional_keyword_defaults(params)
10
10
  mutate_optional_keyword_removal(params)
11
- mutate_keyword_rest_removal(params)
11
+ mutate_keyword_rest_removal(node)
12
12
  end
13
13
 
14
14
  super
@@ -51,9 +51,11 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
51
51
  end
52
52
  end
53
53
 
54
- def mutate_keyword_rest_removal(params)
54
+ def mutate_keyword_rest_removal(node)
55
+ params = node.parameters
55
56
  kr = params.keyword_rest
56
57
  return unless kr.is_a?(Prism::KeywordRestParameterNode)
58
+ return if anonymous_rest_used?(node.body)
57
59
 
58
60
  all_params = collect_all_params(params)
59
61
  if all_params.length < 2
@@ -63,6 +65,27 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
63
65
  end
64
66
  end
65
67
 
68
+ # An anonymous `**` in the body (`bar(**)`, `{ ** }`) only parses while the
69
+ # signature declares one, so removing it there would leave the body
70
+ # unparseable. Finding one also tells the rest is anonymous: Ruby rejects it
71
+ # next to a named rest. A named rest is safe to remove: without it the body
72
+ # reads an undefined name, which parses and fails at runtime.
73
+ def anonymous_rest_used?(body)
74
+ return false if body.nil?
75
+
76
+ uses_anonymous_rest?(body)
77
+ end
78
+
79
+ # A nested def is not searched: an anonymous `**` inside it belongs to that
80
+ # method's own signature. Blocks and lambdas share the enclosing method's
81
+ # parameters, so they are.
82
+ def uses_anonymous_rest?(node)
83
+ return false if node.is_a?(Prism::DefNode)
84
+ return true if node.is_a?(Prism::AssocSplatNode) && node.value.nil?
85
+
86
+ node.compact_child_nodes.any? { |child| uses_anonymous_rest?(child) }
87
+ end
88
+
66
89
  def emit_remove_only_kr(kr)
67
90
  add_mutation(
68
91
  offset: kr.location.start_offset,
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap the values of neighbouring keyword arguments, keeping the keys:
6
+ # `compute(x: a, y: b)` becomes `compute(x: b, y: a)`.
7
+ #
8
+ # A survivor means no example tells the two values apart — the keyword form
9
+ # of the defect ArgumentOrderPermutation probes for positional arguments.
10
+ #
11
+ # Only keyword arguments written in the call are swapped: a braced hash is a
12
+ # single positional argument whose pairs are data. A double splat stays in
13
+ # place, and identical values are not swapped, since that would reproduce the
14
+ # original call. A shorthand key (`compute(a:, b:)`) has no value of its own
15
+ # to move, so the swap spells it out: `compute(a: b, b: a)`.
16
+ class Evilution::Mutator::Operator::KeywordValueSwap < Evilution::Mutator::Base
17
+ def visit_call_node(node)
18
+ swap_keyword_values(node)
19
+ super
20
+ end
21
+
22
+ def visit_super_node(node)
23
+ swap_keyword_values(node)
24
+ super
25
+ end
26
+
27
+ def visit_yield_node(node)
28
+ swap_keyword_values(node)
29
+ super
30
+ end
31
+
32
+ private
33
+
34
+ def swap_keyword_values(node)
35
+ return if node.arguments.nil?
36
+
37
+ node.arguments.arguments.grep(Prism::KeywordHashNode).each do |keywords|
38
+ pairs = keywords.elements.grep(Prism::AssocNode)
39
+ pairs.each_cons(2) do |left, right|
40
+ emit_swap(node, left, right) unless value_text(left) == value_text(right)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Whatever separates the two pairs — the comma, a double splat, line breaks,
46
+ # comments — stays between them, so the call keeps its layout.
47
+ def emit_swap(node, left, right)
48
+ start_offset = left.location.start_offset
49
+
50
+ add_mutation(
51
+ offset: start_offset,
52
+ length: right.location.end_offset - start_offset,
53
+ replacement: "#{key_text(left)}#{value_text(right)}#{source_between(left, right)}" \
54
+ "#{key_text(right)}#{value_text(left)}",
55
+ node: node
56
+ )
57
+ end
58
+
59
+ def source_between(left, right)
60
+ byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
61
+ end
62
+
63
+ # The key with everything up to its value: `x: `, `"x" => `.
64
+ def key_text(pair)
65
+ return "#{pair.key.slice} " if pair.value.is_a?(Prism::ImplicitNode)
66
+
67
+ byteslice_source(pair.location.start_offset, pair.value.location.start_offset - pair.location.start_offset)
68
+ end
69
+
70
+ # A shorthand key stands for the local variable or method of the same name.
71
+ def value_text(pair)
72
+ value = pair.value
73
+ value.is_a?(Prism::ImplicitNode) ? value.value.name.to_s : value.slice
74
+ end
75
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Give a `case/in` that has no `else` an empty one.
6
+ #
7
+ # Without an else, a value no pattern matches raises NoMatchingPatternError;
8
+ # with an empty one the expression quietly yields nil. A survivor means no
9
+ # example feeds the match a value outside its patterns, so nothing asserts
10
+ # that such a value is rejected.
11
+ #
12
+ # The opposite edit, removing an else that is there, belongs to CaseIn.
13
+ class Evilution::Mutator::Operator::NoMatchingPatternElse < Evilution::Mutator::Base
14
+ def visit_case_match_node(node)
15
+ add_else(node) if node.else_clause.nil? && !exhaustive?(node)
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def add_else(node)
22
+ offset = node.end_keyword_loc.start_offset
23
+
24
+ add_mutation(
25
+ offset: offset,
26
+ length: 0,
27
+ replacement: else_clause_before(offset),
28
+ node: node
29
+ )
30
+ end
31
+
32
+ # An `end` on a line of its own gets the else on the line above, indented
33
+ # alike. Otherwise the case is written on one line and the else joins it.
34
+ def else_clause_before(offset)
35
+ line_start = line_start_byte(@file_source, offset)
36
+ indentation = byteslice_source(line_start, offset - line_start)
37
+
38
+ indentation.strip.empty? ? "else\n#{indentation}" : "else; "
39
+ end
40
+
41
+ # A bare name (`in other`, `in _`) matches every value, so an else after it
42
+ # could never run and adding one would change nothing. A guarded capture is
43
+ # not a bare name: Prism wraps it in the guard's conditional.
44
+ def exhaustive?(node)
45
+ node.conditions.any? { |clause| clause.pattern.is_a?(Prism::LocalVariableTargetNode) }
46
+ end
47
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap the numbered parameters of a block: `pairs.map { _1 - _2 }` becomes
6
+ # `pairs.map { _2 - _1 }`.
7
+ #
8
+ # A survivor means no example tells the two values apart, so the block could
9
+ # take them in either order unnoticed.
10
+ #
11
+ # Only parameters the body reads are swapped with each other, each with its
12
+ # neighbour. Swapping in a parameter the body never reads (`{ _1 }` to
13
+ # `{ _2 }`) would change how many values the block takes, which is a different
14
+ # mutation from reordering them.
15
+ class Evilution::Mutator::Operator::NumberedParameterSwap < Evilution::Mutator::Base
16
+ NUMBERED_NAME = /\A_[1-9]\z/
17
+
18
+ def visit_block_node(node)
19
+ swap_parameters(node)
20
+ super
21
+ end
22
+
23
+ def visit_lambda_node(node)
24
+ swap_parameters(node)
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def swap_parameters(node)
31
+ return unless node.parameters.is_a?(Prism::NumberedParametersNode)
32
+
33
+ reads = numbered_reads(node.body)
34
+ names = reads.map { |read| read.name.to_s }.uniq.sort
35
+ names.each_cons(2) { |left, right| emit_swap(node, reads, left, right) }
36
+ end
37
+
38
+ # Every read of a numbered parameter in the body. Blocks nested in it are
39
+ # searched too: a numbered parameter read there can only be this block's,
40
+ # since Ruby rejects a nested block that declares its own. A method defined
41
+ # in the body opens a new scope, whose blocks have parameters of their own.
42
+ def numbered_reads(node)
43
+ return [] if node.is_a?(Prism::DefNode)
44
+ return [node] if node.is_a?(Prism::LocalVariableReadNode) && node.name.to_s.match?(NUMBERED_NAME)
45
+
46
+ node.compact_child_nodes.flat_map { |child| numbered_reads(child) }
47
+ end
48
+
49
+ # The reads are scattered through the body, while a mutation replaces one
50
+ # contiguous range, so the range runs from the first affected read to the
51
+ # last and is rewritten with the two names exchanged.
52
+ def emit_swap(node, reads, left, right)
53
+ affected = reads.select { |read| [left, right].include?(read.name.to_s) }
54
+ .sort_by { |read| read.location.start_offset }
55
+ start_offset = affected.first.location.start_offset
56
+ end_offset = affected.last.location.end_offset
57
+
58
+ add_mutation(
59
+ offset: start_offset,
60
+ length: end_offset - start_offset,
61
+ replacement: exchange(affected, start_offset, end_offset, left => right, right => left),
62
+ node: node
63
+ )
64
+ end
65
+
66
+ def exchange(affected, start_offset, end_offset, renames)
67
+ text = byteslice_source(start_offset, end_offset - start_offset)
68
+
69
+ affected.reverse_each do |read|
70
+ location = read.location
71
+ text = text.byteslice(0, location.start_offset - start_offset) +
72
+ renames.fetch(read.name.to_s) +
73
+ text.byteslice((location.end_offset - start_offset)..)
74
+ end
75
+
76
+ text
77
+ end
78
+ end