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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Make the first step of a `dig` strict: `config.dig(:db, :host)` becomes
6
+ # `config.fetch(:db).dig(:host)`. The reverse of index_to_dig.
7
+ #
8
+ # `dig` returns nil when the first key is missing or holds nil; the fetch
9
+ # chain raises there instead (KeyError / IndexError, or NoMethodError on
10
+ # `nil.dig`). A survivor means no test reaches the lookup with that key
11
+ # absent.
12
+ #
13
+ # Only calls with two or more positional keys qualify; the remaining keys
14
+ # keep their original formatting.
15
+ class Evilution::Mutator::Operator::DigToFetchChain < Evilution::Mutator::Base
16
+ NON_POSITIONAL = [Prism::SplatNode, Prism::KeywordHashNode, Prism::ForwardingArgumentsNode].freeze
17
+ private_constant :NON_POSITIONAL
18
+
19
+ def visit_call_node(node)
20
+ rewrite(node) if node.name == :dig && node.receiver && node.block.nil?
21
+ super
22
+ end
23
+
24
+ private
25
+
26
+ # Replace `dig(first, ` with `fetch(first).dig(` — up to the second key —
27
+ # keeping whatever opened the argument list (`(`, a newline, a space).
28
+ def rewrite(node)
29
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [first, second, *]]
30
+ return unless positional_keys?(node.arguments.arguments)
31
+
32
+ start = node.message_loc.start_offset
33
+ add_mutation(offset: start, length: second.start_offset - start, replacement: fetch_prefix(node, first), node: node)
34
+ end
35
+
36
+ def positional_keys?(keys)
37
+ keys.none? { |key| NON_POSITIONAL.any? { |type| key.is_a?(type) } }
38
+ end
39
+
40
+ def fetch_prefix(node, first)
41
+ message_end = node.message_loc.end_offset
42
+ opener = byteslice_source(message_end, first.start_offset - message_end)
43
+ "fetch(#{source_of(first)}).dig#{opener}"
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a double negation with its operand: `!!value` becomes `value`, and
6
+ # the same for the `not not value` keyword form.
7
+ #
8
+ # A survivor means no test distinguishes a truthy object from `true` (or nil
9
+ # from `false`) — the boolean coercion is never observed.
10
+ class Evilution::Mutator::Operator::DoubleNegationRemoval < Evilution::Mutator::Base
11
+ def visit_call_node(node)
12
+ inner = node.receiver
13
+ promote_child(node, inner.receiver) if negation?(node) && negation?(inner)
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def negation?(node)
20
+ node.is_a?(Prism::CallNode) && node.name == :!
21
+ end
22
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Resolve a dynamic dispatch with a literal symbol into a direct call:
6
+ # `target.send(:reset)` becomes `target.reset`, and the same for `__send__`
7
+ # and `public_send`. Remaining arguments, a block-pass and a literal block
8
+ # stay attached.
9
+ #
10
+ # The two forms differ only in visibility checks, so the mutant is emitted
11
+ # only where those checks can disagree:
12
+ #
13
+ # - send / __send__ on an explicit receiver other than self: the direct call
14
+ # raises NoMethodError for a private method. With an implicit or self
15
+ # receiver a private method resolves either way (Ruby 2.7+).
16
+ # - public_send with an implicit or self receiver: the direct call reaches a
17
+ # private method that public_send refuses. On any other receiver both
18
+ # refuse it, and only a protected method called from inside its class
19
+ # would differ.
20
+ #
21
+ # method_missing is reached the same way by both forms.
22
+ class Evilution::Mutator::Operator::DynamicDispatchResolution < Evilution::Mutator::Base
23
+ SEND_SELECTORS = %i[send __send__].freeze
24
+ private_constant :SEND_SELECTORS
25
+
26
+ OPERATOR_NAMES = %w[
27
+ + - * / % ** == != < > <= >= <=> === =~ !~ ! [] []= << >> & | ^ ~ +@ -@
28
+ ].to_set.freeze
29
+ private_constant :OPERATOR_NAMES
30
+
31
+ METHOD_NAME = /\A[[:alpha:]_][[:alnum:]_]*[?!=]?\z/
32
+ private_constant :METHOD_NAME
33
+
34
+ def visit_call_node(node)
35
+ selector = literal_selector(node)
36
+ resolve(node, selector) if selector && visibility_can_differ?(node)
37
+ super
38
+ end
39
+
40
+ private
41
+
42
+ def literal_selector(node)
43
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [Prism::SymbolNode => symbol, *]]
44
+
45
+ name = symbol.unescaped
46
+ name if name.match?(METHOD_NAME) || OPERATOR_NAMES.include?(name)
47
+ end
48
+
49
+ def visibility_can_differ?(node)
50
+ self_receiver = node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
51
+
52
+ if SEND_SELECTORS.include?(node.name)
53
+ !self_receiver
54
+ else
55
+ node.name == :public_send && self_receiver
56
+ end
57
+ end
58
+
59
+ def resolve(node, selector)
60
+ start = node.message_loc.start_offset
61
+ following = following_item(node)
62
+
63
+ if following
64
+ replacement = "#{selector}#{node.opening_loc ? "(" : " "}"
65
+ finish = following.start_offset
66
+ else
67
+ replacement = selector
68
+ finish = node.closing_loc ? node.closing_loc.end_offset : node.arguments.end_offset
69
+ end
70
+
71
+ add_mutation(offset: start, length: finish - start, replacement: replacement, node: node)
72
+ end
73
+
74
+ # The argument after the selector, or the block-pass when the selector is
75
+ # the only argument.
76
+ def following_item(node)
77
+ node.arguments.arguments[1] || (node.block if node.block.is_a?(Prism::BlockArgumentNode))
78
+ end
79
+ end
@@ -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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Rewrite an inequality with stricter comparisons: `a != b` becomes
6
+ # `!a.eql?(b)` and `!a.equal?(b)`.
7
+ #
8
+ # `eql?` adds a type check (`1 != 1.0` is false, `!1.eql?(1.0)` is true) and
9
+ # `equal?` compares identity (two equal strings or arrays are still
10
+ # different objects). A survivor means no test separates value equality from
11
+ # the stricter forms, so the comparison in use is never pinned down.
12
+ #
13
+ # Comparisons against nil, true, false or a symbol literal are skipped: those
14
+ # are singletons, so all three forms always agree.
15
+ class Evilution::Mutator::Operator::InequalityToNegatedIdentity < Evilution::Mutator::Base
16
+ SELECTORS = %i[eql? equal?].freeze
17
+ private_constant :SELECTORS
18
+
19
+ SINGLETON_LITERALS = [Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::SymbolNode].freeze
20
+ private_constant :SINGLETON_LITERALS
21
+
22
+ def visit_call_node(node)
23
+ rewrite(node) if node.name == :!=
24
+ super
25
+ end
26
+
27
+ private
28
+
29
+ def rewrite(node)
30
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
31
+ return if [node.receiver, argument].any? { |operand| singleton_literal?(operand) }
32
+
33
+ left = receiver_source(node.receiver)
34
+ SELECTORS.each do |selector|
35
+ add_mutation(offset: node.start_offset, length: node.location.length,
36
+ replacement: "!#{left}.#{selector}(#{source_of(argument)})", node: node)
37
+ end
38
+ end
39
+
40
+ def singleton_literal?(operand)
41
+ SINGLETON_LITERALS.any? { |type| operand.is_a?(type) }
42
+ end
43
+ 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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Drop one keyword argument at a call site: `build(name: "x", size: 2)`
6
+ # becomes `build(size: 2)` and `build(name: "x")`. Applies to any argument
7
+ # list — calls, `super` and `yield`.
8
+ #
9
+ # A survivor means the callee never consumes that keyword, or its default
10
+ # happens to match. argument_removal and argument_nil_substitution skip
11
+ # keyword arguments entirely.
12
+ #
13
+ # Only `key: value` pairs are dropped; a `**opts` double splat stays, though
14
+ # it still counts as a neighbour when removing the separator. A lone keyword
15
+ # that is the whole argument list is left to argument_list_removal.
16
+ class Evilution::Mutator::Operator::KeywordArgumentRemoval < Evilution::Mutator::Base
17
+ def visit_arguments_node(node)
18
+ node.arguments.each_with_index do |argument, index|
19
+ next unless argument.is_a?(Prism::KeywordHashNode)
20
+
21
+ drop_keywords(argument, index.zero? ? nil : node.arguments[index - 1])
22
+ end
23
+
24
+ super
25
+ end
26
+
27
+ private
28
+
29
+ def drop_keywords(hash, previous_argument)
30
+ elements = hash.elements
31
+ return if elements.length == 1 && previous_argument.nil?
32
+
33
+ elements.each_with_index do |element, index|
34
+ next unless element.is_a?(Prism::AssocNode)
35
+
36
+ drop_element(element, elements[index + 1], index.zero? ? previous_argument : elements[index - 1])
37
+ end
38
+ end
39
+
40
+ # Cut from the element to the next one, or — for the last element — from
41
+ # the end of whatever precedes it, so the separating comma goes too.
42
+ def drop_element(element, following, preceding)
43
+ start, finish = following ? [element.start_offset, following.start_offset] : [preceding.end_offset, element.end_offset]
44
+
45
+ add_mutation(offset: start, length: finish - start, replacement: "", node: element, skip_unparseable: true)
46
+ end
47
+ end