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,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
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace safe navigation with a plain call: `user&.name` becomes
6
+ # `user.name`, and the same for the `||=`, `&&=` and operator-write forms.
7
+ #
8
+ # The mutant only differs when the receiver is nil, where it raises
9
+ # NoMethodError instead of short-circuiting. A survivor means no test ever
10
+ # reaches the call with a nil receiver, so the `&.` is unexercised.
11
+ #
12
+ # Receivers that can never be nil — `self` and literals — are skipped, since
13
+ # the mutant is equivalent there.
14
+ class Evilution::Mutator::Operator::SafeNavigationRemoval < Evilution::Mutator::Base
15
+ NEVER_NIL_RECEIVERS = [
16
+ Prism::SelfNode,
17
+ Prism::StringNode,
18
+ Prism::InterpolatedStringNode,
19
+ Prism::XStringNode,
20
+ Prism::InterpolatedXStringNode,
21
+ Prism::SymbolNode,
22
+ Prism::InterpolatedSymbolNode,
23
+ Prism::IntegerNode,
24
+ Prism::FloatNode,
25
+ Prism::RationalNode,
26
+ Prism::ImaginaryNode,
27
+ Prism::ArrayNode,
28
+ Prism::HashNode,
29
+ Prism::RangeNode,
30
+ Prism::RegularExpressionNode,
31
+ Prism::InterpolatedRegularExpressionNode,
32
+ Prism::TrueNode,
33
+ Prism::FalseNode,
34
+ Prism::LambdaNode
35
+ ].freeze
36
+ private_constant :NEVER_NIL_RECEIVERS
37
+
38
+ def visit_call_node(node)
39
+ mutate_safe_navigation(node)
40
+ super
41
+ end
42
+
43
+ def visit_call_or_write_node(node)
44
+ mutate_safe_navigation(node)
45
+ super
46
+ end
47
+
48
+ def visit_call_and_write_node(node)
49
+ mutate_safe_navigation(node)
50
+ super
51
+ end
52
+
53
+ def visit_call_operator_write_node(node)
54
+ mutate_safe_navigation(node)
55
+ super
56
+ end
57
+
58
+ private
59
+
60
+ def mutate_safe_navigation(node)
61
+ return unless node.safe_navigation?
62
+ return if NEVER_NIL_RECEIVERS.any? { |type| node.receiver.is_a?(type) }
63
+
64
+ operator = node.call_operator_loc
65
+ add_mutation(offset: operator.start_offset, length: operator.length, replacement: ".", node: node)
66
+ end
67
+ end
@@ -25,22 +25,52 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
25
25
  count: [:size],
26
26
  select: [:filter],
27
27
  filter: [:select],
28
- to_s: [:to_i],
29
- to_i: [:to_s],
28
+ to_s: %i[to_i to_str],
29
+ to_i: %i[to_s to_int],
30
30
  to_f: [:to_i],
31
- to_a: [:to_h],
32
- to_h: [:to_a],
31
+ to_a: %i[to_h to_ary],
32
+ to_h: %i[to_a to_hash],
33
33
  downcase: [:upcase],
34
34
  upcase: [:downcase],
35
35
  strip: %i[lstrip rstrip],
36
36
  lstrip: [:strip],
37
37
  rstrip: [:strip],
38
38
  chomp: [:chop],
39
- chop: [:chomp]
39
+ chop: [:chomp],
40
+ bytes: [:chars],
41
+ chars: [:bytes],
42
+ start_with?: [:end_with?],
43
+ end_with?: [:start_with?],
44
+ ceil: [:floor],
45
+ floor: [:ceil],
46
+ round: %i[floor ceil],
47
+ truncate: [:floor],
48
+ transform_keys: [:transform_values],
49
+ transform_values: [:transform_keys],
50
+ append: [:prepend],
51
+ prepend: [:append],
52
+ reverse_merge: [:merge],
53
+ even?: [:odd?],
54
+ odd?: [:even?],
55
+ zero?: [:nonzero?],
56
+ negative?: [:positive?],
57
+ positive?: [:negative?],
58
+ pred: [:succ],
59
+ succ: [:pred],
60
+ is_a?: [:instance_of?],
61
+ kind_of?: [:instance_of?]
62
+ }.freeze
63
+
64
+ # Swapped only when called with exactly one argument. Kept out of
65
+ # REPLACEMENTS, which symbol_to_proc_replacement also reads: `&:method`
66
+ # always calls with none, and a zero-argument `method` is usually an
67
+ # accessor such as `request.method` (the HTTP verb), not Object#method.
68
+ ONE_ARGUMENT_REPLACEMENTS = {
69
+ method: [:public_method]
40
70
  }.freeze
41
71
 
42
72
  def visit_call_node(node)
43
- replacements = REPLACEMENTS[node.name]
73
+ replacements = replacements_for(node)
44
74
  return super unless replacements
45
75
  return super unless node.receiver
46
76
 
@@ -58,4 +88,12 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
58
88
 
59
89
  super
60
90
  end
91
+
92
+ private
93
+
94
+ def replacements_for(node)
95
+ REPLACEMENTS.fetch(node.name) do
96
+ ONE_ARGUMENT_REPLACEMENTS[node.name] if node.arguments && node.arguments.arguments.length == 1
97
+ end
98
+ end
61
99
  end
@@ -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,