evilution 1.2.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 (213) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +100 -0
  3. data/.rubocop_todo.yml +14 -0
  4. data/CHANGELOG.md +124 -0
  5. data/README.md +142 -28
  6. data/docs/architecture.md +123 -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 +120 -16
  15. data/lib/evilution/ast/pattern/filter.rb +1 -0
  16. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  17. data/lib/evilution/ast/pattern/parser.rb +15 -15
  18. data/lib/evilution/ast/pattern.rb +2 -0
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/scope_declaration.rb +45 -0
  21. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  22. data/lib/evilution/ast/uncovered_code.rb +88 -0
  23. data/lib/evilution/ast/value_object_definition.rb +25 -0
  24. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  25. data/lib/evilution/baseline/report.rb +66 -0
  26. data/lib/evilution/baseline/spec_failure.rb +38 -0
  27. data/lib/evilution/baseline.rb +101 -42
  28. data/lib/evilution/child_output.rb +1 -1
  29. data/lib/evilution/cli/command.rb +1 -0
  30. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  31. data/lib/evilution/cli/commands.rb +2 -0
  32. data/lib/evilution/cli/dispatcher.rb +2 -0
  33. data/lib/evilution/cli/exit_guard.rb +2 -0
  34. data/lib/evilution/cli/parsed_args.rb +2 -0
  35. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  36. data/lib/evilution/cli/parser/file_args.rb +4 -1
  37. data/lib/evilution/cli/parser/options_builder.rb +4 -1
  38. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  39. data/lib/evilution/cli/parser.rb +1 -0
  40. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  41. data/lib/evilution/cli/printers.rb +2 -0
  42. data/lib/evilution/cli/result.rb +2 -0
  43. data/lib/evilution/cli.rb +8 -2
  44. data/lib/evilution/config/builders.rb +2 -0
  45. data/lib/evilution/config/env_loader.rb +2 -0
  46. data/lib/evilution/config/file_loader.rb +1 -0
  47. data/lib/evilution/config/sources.rb +1 -0
  48. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  49. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  50. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  51. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  52. data/lib/evilution/config/validators/hooks.rb +1 -0
  53. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  54. data/lib/evilution/config/validators/integration.rb +1 -0
  55. data/lib/evilution/config/validators/isolation.rb +1 -0
  56. data/lib/evilution/config/validators/jobs.rb +1 -0
  57. data/lib/evilution/config/validators/preload.rb +1 -0
  58. data/lib/evilution/config/validators/profile.rb +1 -0
  59. data/lib/evilution/config/validators/spec_mappings.rb +3 -1
  60. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  61. data/lib/evilution/config/validators/warmup.rb +17 -0
  62. data/lib/evilution/config/validators.rb +2 -0
  63. data/lib/evilution/config.rb +7 -5
  64. data/lib/evilution/coverage.rb +1 -1
  65. data/lib/evilution/coverage_example_filter.rb +1 -1
  66. data/lib/evilution/diagnostic.rb +1 -1
  67. data/lib/evilution/equivalent/detector.rb +3 -1
  68. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  69. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  70. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  71. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  72. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  73. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  74. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  75. data/lib/evilution/example_filter.rb +46 -1
  76. data/lib/evilution/hooks/registry.rb +2 -1
  77. data/lib/evilution/integration/base.rb +2 -0
  78. data/lib/evilution/integration/known_failures.rb +26 -0
  79. data/lib/evilution/integration/loading/body_call_neutralizer.rb +139 -12
  80. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  81. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  82. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  83. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  84. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  85. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  86. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  87. data/lib/evilution/integration/minitest.rb +45 -6
  88. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  89. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  90. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  91. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  92. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  93. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  94. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  95. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  96. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  97. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  98. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  99. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  100. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  101. data/lib/evilution/integration/rspec/state_guard.rb +10 -1
  102. data/lib/evilution/integration/rspec.rb +22 -4
  103. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  104. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  105. data/lib/evilution/integration/test_unit.rb +37 -9
  106. data/lib/evilution/isolation/fork.rb +2 -1
  107. data/lib/evilution/isolation/in_process.rb +21 -4
  108. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  109. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  110. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  111. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  112. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  113. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  114. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  115. data/lib/evilution/memory/leak_check.rb +27 -3
  116. data/lib/evilution/mutation.rb +8 -3
  117. data/lib/evilution/mutator/base.rb +49 -5
  118. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  119. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  120. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  121. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  122. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  123. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  124. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  125. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  126. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  127. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  128. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  129. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  130. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  131. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  132. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  133. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  134. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  135. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  136. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  137. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  138. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  139. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  140. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  141. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  142. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  143. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  144. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  145. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  146. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  147. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  148. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  149. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  150. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  151. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  152. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  153. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  154. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  155. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  156. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  157. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  158. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  159. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  160. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  161. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  162. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  163. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  164. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  165. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  166. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  167. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  168. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  169. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  170. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  171. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  172. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  173. data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
  174. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  175. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  176. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  177. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  178. data/lib/evilution/mutator/primitives.rb +25 -0
  179. data/lib/evilution/mutator/registry.rb +54 -2
  180. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  181. data/lib/evilution/parallel/pool.rb +1 -0
  182. data/lib/evilution/parallel_db_warning.rb +1 -1
  183. data/lib/evilution/process_supervisor.rb +3 -2
  184. data/lib/evilution/rails_warmup.rb +61 -0
  185. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  186. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  187. data/lib/evilution/reporter/html/report.rb +1 -0
  188. data/lib/evilution/reporter/json/baseline.rb +15 -0
  189. data/lib/evilution/reporter/json.rb +7 -2
  190. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  191. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  192. data/lib/evilution/result/mutation_result.rb +9 -1
  193. data/lib/evilution/result/subject_scorer.rb +1 -0
  194. data/lib/evilution/result/summary.rb +42 -2
  195. data/lib/evilution/runner/baseline_runner.rb +26 -11
  196. data/lib/evilution/runner/canary.rb +2 -57
  197. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  198. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  199. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
  200. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  201. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  202. data/lib/evilution/runner/mutation_executor.rb +1 -1
  203. data/lib/evilution/runner/mutation_planner.rb +11 -2
  204. data/lib/evilution/runner/report_publisher.rb +3 -2
  205. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  206. data/lib/evilution/runner.rb +4 -1
  207. data/lib/evilution/source_ast_cache.rb +1 -1
  208. data/lib/evilution/spec_ast_cache.rb +1 -1
  209. data/lib/evilution/subject.rb +8 -2
  210. data/lib/evilution/version.rb +1 -1
  211. data/lib/evilution.rb +51 -0
  212. data/script/memory_check +62 -16
  213. metadata +108 -4
@@ -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
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Shift a count held in a variable down by one: `n.times` becomes
6
+ # `(n - 1).times`, `items.first(n)` becomes `items.first(n - 1)`.
7
+ #
8
+ # A survivor means no example checks the last iteration or the last element,
9
+ # the classic fencepost slip. A literal count is left to IntegerLiteral, which
10
+ # already shifts it; this operator reaches the counts that have no literal.
11
+ class Evilution::Mutator::Operator::OffByOneBoundary < Evilution::Mutator::Base
12
+ # Methods whose receiver is the count.
13
+ RECEIVER_COUNT_METHODS = %i[times].freeze
14
+
15
+ # Methods whose single argument is the count or the bound.
16
+ ARGUMENT_COUNT_METHODS = %i[upto downto take first last drop each_slice each_cons].freeze
17
+
18
+ # Arguments that stand in the list without being a value: a splat,
19
+ # keywords, forwarded arguments. Subtracting from them would not parse.
20
+ NON_VALUE_ARGUMENT_TYPES = [
21
+ Prism::SplatNode, Prism::KeywordHashNode, Prism::ForwardingArgumentsNode
22
+ ].freeze
23
+
24
+ def visit_call_node(node)
25
+ shift_count(node) unless node.safe_navigation?
26
+ super
27
+ end
28
+
29
+ private
30
+
31
+ def shift_count(node)
32
+ count = count_of(node)
33
+ return if count.nil? || count.is_a?(Prism::IntegerNode)
34
+
35
+ add_mutation(
36
+ offset: count.location.start_offset,
37
+ length: count.location.length,
38
+ replacement: lowered(node, count),
39
+ node: node
40
+ )
41
+ end
42
+
43
+ def count_of(node)
44
+ if RECEIVER_COUNT_METHODS.include?(node.name)
45
+ node.receiver if node.arguments.nil?
46
+ elsif ARGUMENT_COUNT_METHODS.include?(node.name)
47
+ single_argument(node)
48
+ end
49
+ end
50
+
51
+ def single_argument(node)
52
+ return nil if node.arguments.nil?
53
+
54
+ arguments = node.arguments.arguments
55
+ return nil unless arguments.length == 1
56
+ return nil if NON_VALUE_ARGUMENT_TYPES.include?(arguments.first.class)
57
+
58
+ arguments.first
59
+ end
60
+
61
+ # A receiver is followed by `.method`, so the subtraction is grouped; an
62
+ # argument stands on its own and only needs grouping when it is not a
63
+ # primary expression.
64
+ def lowered(node, count)
65
+ subtraction = "#{receiver_source(count)} - 1"
66
+ count.equal?(node.receiver) ? "(#{subtraction})" : subtraction
67
+ end
68
+ end
@@ -12,21 +12,32 @@ require_relative "../operator"
12
12
  # that pass the argument are unaffected, which is what separates this from a
13
13
  # mutation that breaks every call site.
14
14
  #
15
- # Ruby allows a required parameter to follow an optional one, so each optional
16
- # is mutated on its own and the rest of the signature stays as written.
15
+ # Optional parameters have to sit side by side, so only the ends of a run of
16
+ # them are mutated; a required parameter in the middle would split it in two.
17
+ # The first can always join the required parameters before it. The last can
18
+ # join those after it, unless a rest parameter follows: a required parameter
19
+ # cannot stand between an optional one and `*rest`. The rest of the signature
20
+ # stays as written.
17
21
  # KeywordArgument owns optional keyword parameters. Blocks are left alone: a
18
22
  # block ignores arity, so a missing argument arrives as nil rather than raising,
19
23
  # and making its parameter required would change nothing.
20
24
  class Evilution::Mutator::Operator::OptionalParameterToRequired < Evilution::Mutator::Base
21
25
  def visit_def_node(node)
22
26
  parameters = node.parameters
23
- parameters.optionals.each { |optional| require_parameter(optional) } if parameters
27
+ mutable_optionals(parameters).each { |optional| require_parameter(optional) } if parameters
24
28
 
25
29
  super
26
30
  end
27
31
 
28
32
  private
29
33
 
34
+ def mutable_optionals(parameters)
35
+ optionals = parameters.optionals
36
+ return optionals.first(1) if parameters.rest
37
+
38
+ [optionals.first, optionals.last].compact.uniq
39
+ end
40
+
30
41
  def require_parameter(node)
31
42
  location = node.location
32
43
  name_loc = node.name_loc
@@ -83,14 +83,25 @@ class Evilution::Mutator::Operator::PatternMatchingArray < Evilution::Mutator::B
83
83
  end
84
84
 
85
85
  def replace_pattern(node, parts)
86
+ constant = node.constant ? source_for(node.constant) : ""
87
+ opening, closing = delimiters(node)
88
+
86
89
  add_mutation(
87
90
  offset: node.location.start_offset,
88
91
  length: node.location.length,
89
- replacement: "[#{parts.join(", ")}]",
92
+ replacement: "#{constant}#{opening}#{parts.join(", ")}#{closing}",
90
93
  node: node
91
94
  )
92
95
  end
93
96
 
97
+ # A pattern written without brackets (`in a, b`) has no delimiters of its
98
+ # own, so the mutant is given square ones.
99
+ def delimiters(node)
100
+ return ["[", "]"] if node.opening_loc.nil?
101
+
102
+ [node.opening_loc.slice, node.closing_loc.slice]
103
+ end
104
+
94
105
  def source_for(node)
95
106
  @file_source.byteslice(node.location.start_offset, node.location.length)
96
107
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Widen a pattern so it accepts input the original rejects:
6
+ #
7
+ # in [a, b] -> in [a, b, *] longer arrays
8
+ # in { age: Integer } -> in { age: _ } any value under the key
9
+ # in { name: String, age: Integer }
10
+ # -> in { name: String } hashes without the key
11
+ # in { kind: :a, **nil } -> in { kind: :a } hashes with other keys
12
+ #
13
+ # A survivor means no example feeds the pattern one of the shapes it is written
14
+ # to turn away.
15
+ #
16
+ # Elements of array and find patterns are widened by PatternMatchingArray. Here
17
+ # the pattern itself is opened up, and every edit keeps the variables the
18
+ # pattern binds, so the branch body still runs as written.
19
+ class Evilution::Mutator::Operator::PatternWildcardWidening < Evilution::Mutator::Base
20
+ WILDCARD = "_"
21
+ UNWIDENABLE_VALUE_TYPES = [Prism::LocalVariableTargetNode, Prism::ImplicitNode].freeze
22
+
23
+ def visit_array_pattern_node(node)
24
+ append_rest(node)
25
+ super
26
+ end
27
+
28
+ def visit_hash_pattern_node(node)
29
+ widen_pairs(node)
30
+ drop_closing_rest(node)
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ # A pattern with a rest is open already, at whichever end the rest sits, and
37
+ # an empty one has no element to append after.
38
+ def append_rest(node)
39
+ return unless node.rest.nil?
40
+ return if node.requireds.empty?
41
+
42
+ add_mutation(
43
+ offset: node.requireds.last.location.end_offset,
44
+ length: 0,
45
+ replacement: ", *",
46
+ node: node
47
+ )
48
+ end
49
+
50
+ def widen_pairs(node)
51
+ node.elements.each_with_index do |pair, index|
52
+ next if binds_variable?(pair.value)
53
+
54
+ wildcard_value(node, pair)
55
+ # `in {}` matches the empty hash only, so dropping the last key would
56
+ # narrow the pattern rather than widen it.
57
+ drop_pair(node, index) if node.elements.length > 1
58
+ end
59
+ end
60
+
61
+ # A bare name reaching here is an underscore one, which already accepts any
62
+ # value; rewriting it to `_` would change nothing. A shorthand key
63
+ # (`_ignored:`) has no value pattern of its own: its implicit value spans the
64
+ # key, so writing a wildcard there would rename the key instead.
65
+ def wildcard_value(node, pair)
66
+ return if UNWIDENABLE_VALUE_TYPES.include?(pair.value.class)
67
+
68
+ location = pair.value.location
69
+
70
+ add_mutation(
71
+ offset: location.start_offset,
72
+ length: location.length,
73
+ replacement: WILDCARD,
74
+ node: node
75
+ )
76
+ end
77
+
78
+ def drop_pair(node, index)
79
+ remaining = node.elements.dup
80
+ remaining.delete_at(index)
81
+ replace_members(node, remaining + [node.rest].compact)
82
+ end
83
+
84
+ # `**nil` forbids keys the pattern does not name. On its own it leaves a
85
+ # pattern that matches the empty hash only, with or without it.
86
+ def drop_closing_rest(node)
87
+ return unless node.rest.is_a?(Prism::NoKeywordsParameterNode)
88
+ return if node.elements.empty?
89
+
90
+ replace_members(node, node.elements)
91
+ end
92
+
93
+ # Rewrites what sits between the delimiters, so braces, parentheses and a
94
+ # constant in front of them stay as written.
95
+ def replace_members(node, members)
96
+ all = node.elements + [node.rest].compact
97
+ start_offset = all.first.location.start_offset
98
+ end_offset = all.last.location.end_offset
99
+
100
+ add_mutation(
101
+ offset: start_offset,
102
+ length: end_offset - start_offset,
103
+ replacement: members.map(&:slice).join(", "),
104
+ node: node
105
+ )
106
+ end
107
+
108
+ # Whether the value pattern captures into a variable the branch body could
109
+ # read. Taking such a pair away, or widening it to a wildcard, would break
110
+ # the body on the missing name whatever the tests assert. An underscore name
111
+ # is a wildcard by convention and is not counted.
112
+ def binds_variable?(node)
113
+ return !node.name.start_with?(WILDCARD) if node.is_a?(Prism::LocalVariableTargetNode)
114
+
115
+ node.compact_child_nodes.any? { |child| binds_variable?(child) }
116
+ end
117
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Drop the pin of a pattern variable: `in ^expected` becomes `in expected`.
6
+ #
7
+ # A pinned variable compares the matched value against an existing binding;
8
+ # without the pin the same name captures whatever is there, so the pattern
9
+ # matches every value. A survivor means no example feeds the pattern a value
10
+ # that differs from the pinned one.
11
+ class Evilution::Mutator::Operator::PinOperatorRemoval < Evilution::Mutator::Base
12
+ def initialize(**options)
13
+ super
14
+ @alternation_depth = 0
15
+ end
16
+
17
+ # An alternative pattern (`^a | ^b`) may not capture, at any depth inside it,
18
+ # so no pin under one has an unpinned form. Tracked here rather than left to
19
+ # the parse check: older Prism releases accept the capture that Ruby rejects.
20
+ def visit_alternation_pattern_node(node)
21
+ @alternation_depth += 1
22
+ super
23
+ ensure
24
+ @alternation_depth -= 1
25
+ end
26
+
27
+ def visit_pinned_variable_node(node)
28
+ remove_pin(node) if @alternation_depth.zero?
29
+ super
30
+ end
31
+
32
+ private
33
+
34
+ # Other pins without an unpinned form are caught by the parse check. Only a
35
+ # local name can stand as a capturing pattern, so `^@expected` and
36
+ # `^$expected` have none, and neither does a numbered parameter (`^_1`).
37
+ # Those rewrites carry no signal, so they are dropped rather than reported
38
+ # as unparseable.
39
+ def remove_pin(node)
40
+ add_mutation(
41
+ offset: node.location.start_offset,
42
+ length: node.location.length,
43
+ replacement: node.variable.slice,
44
+ node: node,
45
+ skip_unparseable: true
46
+ )
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Turn a proc into a lambda: `proc { |a, b| ... }` and `Proc.new { ... }`
6
+ # become `lambda { ... }`, with the block itself untouched.
7
+ #
8
+ # A lambda checks arity (wrong argument counts raise ArgumentError instead
9
+ # of padding with nil or dropping extras), does not auto-splat a single
10
+ # array argument, and `return` inside it returns from the lambda rather than
11
+ # from the enclosing method. A survivor means no test relies on any of that.
12
+ #
13
+ # Only a literal block with no arguments qualifies: `proc(&blk)` and
14
+ # `Proc.new(x) { }` are skipped.
15
+ class Evilution::Mutator::Operator::ProcToLambda < Evilution::Mutator::Base
16
+ def visit_call_node(node)
17
+ rewrite(node) if proc_constructor?(node) && node.block.is_a?(Prism::BlockNode) && node.arguments.nil?
18
+ super
19
+ end
20
+
21
+ private
22
+
23
+ def proc_constructor?(node)
24
+ case node.name
25
+ when :proc then node.receiver.nil?
26
+ when :new then proc_constant?(node.receiver)
27
+ end
28
+ end
29
+
30
+ def proc_constant?(receiver)
31
+ case receiver
32
+ when Prism::ConstantReadNode then receiver.name == :Proc
33
+ when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Proc
34
+ end
35
+ end
36
+
37
+ # Replace the call head — `proc`, `Proc.new` or `Proc.new()` — leaving the
38
+ # block in place.
39
+ def rewrite(node)
40
+ head_end = (node.closing_loc || node.message_loc).end_offset
41
+ add_mutation(offset: node.start_offset, length: head_end - node.start_offset, replacement: "lambda", node: node)
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap a constructor for a sibling on the same receiver constant:
6
+ # `Date.parse(value)` becomes `Date.iso8601(value)`, `Date.strptime(value)`,
7
+ # `Date.rfc2822(value)` and the rest. Keyed on receiver and selector, which
8
+ # the plain selector tables cannot express — `JSON.parse` is untouched.
9
+ #
10
+ # `parse` accepts almost any date-like string; the siblings accept one
11
+ # format (strptime's default is ISO 8601) or, like `jd` / `civil`, numbers
12
+ # only. A survivor means the tests only ever feed input every parser agrees
13
+ # on, so an overly lenient parse goes unnoticed.
14
+ #
15
+ # Only the constant itself (`Date`, `::Date`) matches, not a namespaced
16
+ # `Acme::Date`.
17
+ class Evilution::Mutator::Operator::ReceiverConstructorSwap < Evilution::Mutator::Base
18
+ DATE_PARSE_SIBLINGS = %i[jd civil strptime iso8601 rfc3339 xmlschema rfc2822 rfc822 httpdate jisx0301].freeze
19
+ private_constant :DATE_PARSE_SIBLINGS
20
+
21
+ # receiver constant => { selector => replacements }. Time has no jd / civil
22
+ # / rfc3339 / jisx0301, and its strptime requires a format argument.
23
+ REPLACEMENTS = {
24
+ Date: { parse: DATE_PARSE_SIBLINGS },
25
+ DateTime: { parse: DATE_PARSE_SIBLINGS },
26
+ Time: { parse: %i[iso8601 xmlschema rfc2822 rfc822 httpdate] }
27
+ }.freeze
28
+ private_constant :REPLACEMENTS
29
+
30
+ def visit_call_node(node)
31
+ replacements_for(node).each do |replacement|
32
+ location = node.message_loc
33
+ add_mutation(offset: location.start_offset, length: location.length, replacement: replacement.to_s, node: node)
34
+ end
35
+ super
36
+ end
37
+
38
+ private
39
+
40
+ def replacements_for(node)
41
+ selectors = REPLACEMENTS[receiver_constant(node.receiver)]
42
+ selectors ? selectors.fetch(node.name, []) : []
43
+ end
44
+
45
+ def receiver_constant(receiver)
46
+ case receiver
47
+ when Prism::ConstantReadNode then receiver.name
48
+ when Prism::ConstantPathNode then receiver.name if receiver.parent.nil?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Rewrite a `+` reduction into `sum`: `values.reduce(:+)` becomes
6
+ # `values.sum`, `values.inject(10, :+)` becomes `values.sum(10)`, and the
7
+ # same for the block-pass form `inject(&:+)`.
8
+ #
9
+ # The two differ on an empty collection (nil vs 0), on non-numeric elements
10
+ # (`reduce` concatenates strings and arrays, `sum` raises TypeError without a
11
+ # matching initial value) and on float precision (`sum` compensates rounding
12
+ # error). A survivor means none of those edges is tested.
13
+ class Evilution::Mutator::Operator::ReduceToSum < Evilution::Mutator::Base
14
+ SELECTORS = %i[reduce inject].freeze
15
+ private_constant :SELECTORS
16
+
17
+ NON_VALUE_ARGUMENTS = [Prism::SplatNode, Prism::KeywordHashNode].freeze
18
+ private_constant :NON_VALUE_ARGUMENTS
19
+
20
+ def visit_call_node(node)
21
+ rewrite(node) if node.receiver && SELECTORS.include?(node.name)
22
+ super
23
+ end
24
+
25
+ private
26
+
27
+ def rewrite(node)
28
+ initial = initial_arguments(node)
29
+ return unless initial && initial.length <= 1
30
+ return if initial.any? { |argument| NON_VALUE_ARGUMENTS.any? { |type| argument.is_a?(type) } }
31
+
32
+ # A literal block never reaches here, so the call ends at its argument
33
+ # list or block-pass: cut from the selector to the end of the call.
34
+ start = node.message_loc.start_offset
35
+ replacement = initial.empty? ? "sum" : "sum(#{source_of(initial.first)})"
36
+ add_mutation(offset: start, length: node.end_offset - start, replacement: replacement, node: node)
37
+ end
38
+
39
+ # The arguments besides the `+` operand — at most an initial value — when
40
+ # the call is a `+` reduction; nil otherwise.
41
+ def initial_arguments(node)
42
+ arguments = node.arguments ? node.arguments.arguments : []
43
+
44
+ if plus_block_pass?(node.block)
45
+ arguments unless plus_symbol?(arguments.last)
46
+ elsif node.block.nil? && plus_symbol?(arguments.last)
47
+ arguments[...-1]
48
+ end
49
+ end
50
+
51
+ def plus_block_pass?(block)
52
+ block.is_a?(Prism::BlockArgumentNode) && plus_symbol?(block.expression)
53
+ end
54
+
55
+ def plus_symbol?(node)
56
+ node.is_a?(Prism::SymbolNode) && node.unescaped == "+"
57
+ end
58
+ end