evilution 1.1.0 → 1.3.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +58 -0
  3. data/.rubocop_todo.yml +9 -0
  4. data/CHANGELOG.md +86 -0
  5. data/README.md +202 -14
  6. data/docs/architecture.md +54 -5
  7. data/docs/isolation.md +3 -4
  8. data/exe/evil +8 -1
  9. data/exe/evilution +8 -1
  10. data/lib/evilution/ast/local_reads.rb +43 -0
  11. data/lib/evilution/ast/parser.rb +1 -0
  12. data/lib/evilution/ast/pattern/filter.rb +1 -0
  13. data/lib/evilution/ast/pattern/parser.rb +1 -0
  14. data/lib/evilution/ast/pattern.rb +2 -0
  15. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  16. data/lib/evilution/baseline.rb +37 -15
  17. data/lib/evilution/child_output.rb +1 -1
  18. data/lib/evilution/cli/command.rb +1 -0
  19. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  20. data/lib/evilution/cli/commands.rb +2 -0
  21. data/lib/evilution/cli/dispatcher.rb +2 -0
  22. data/lib/evilution/cli/exit_guard.rb +61 -0
  23. data/lib/evilution/cli/parsed_args.rb +2 -0
  24. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  25. data/lib/evilution/cli/parser/file_args.rb +2 -0
  26. data/lib/evilution/cli/parser/options_builder.rb +4 -0
  27. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  28. data/lib/evilution/cli/parser.rb +1 -0
  29. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  30. data/lib/evilution/cli/printers.rb +2 -0
  31. data/lib/evilution/cli/result.rb +2 -0
  32. data/lib/evilution/cli.rb +6 -0
  33. data/lib/evilution/config/builders.rb +2 -0
  34. data/lib/evilution/config/env_loader.rb +2 -0
  35. data/lib/evilution/config/file_loader.rb +1 -0
  36. data/lib/evilution/config/sources.rb +1 -0
  37. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  38. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  39. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  40. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  41. data/lib/evilution/config/validators/hooks.rb +1 -0
  42. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  43. data/lib/evilution/config/validators/integration.rb +1 -0
  44. data/lib/evilution/config/validators/isolation.rb +1 -0
  45. data/lib/evilution/config/validators/jobs.rb +1 -0
  46. data/lib/evilution/config/validators/preload.rb +1 -0
  47. data/lib/evilution/config/validators/profile.rb +1 -0
  48. data/lib/evilution/config/validators/spec_mappings.rb +1 -0
  49. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  50. data/lib/evilution/config/validators/warmup.rb +17 -0
  51. data/lib/evilution/config/validators.rb +2 -0
  52. data/lib/evilution/config.rb +7 -5
  53. data/lib/evilution/coverage.rb +1 -1
  54. data/lib/evilution/coverage_example_filter.rb +1 -1
  55. data/lib/evilution/diagnostic.rb +22 -0
  56. data/lib/evilution/example_filter.rb +1 -1
  57. data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
  58. data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
  59. data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
  60. data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
  61. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  62. data/lib/evilution/integration/minitest.rb +2 -1
  63. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
  64. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  65. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +5 -1
  66. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  67. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  68. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  69. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  70. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  71. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  72. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  73. data/lib/evilution/integration/rspec/state_guard.rb +6 -0
  74. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
  75. data/lib/evilution/integration/rspec.rb +48 -2
  76. data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
  77. data/lib/evilution/isolation/fork.rb +29 -8
  78. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  79. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  80. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  81. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  82. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  83. data/lib/evilution/mutation.rb +1 -1
  84. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  85. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  86. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  87. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  88. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  89. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  90. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  91. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  92. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  93. data/lib/evilution/mutator/operator/block_destructuring_expansion.rb +85 -0
  94. data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -0
  95. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  96. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  97. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  98. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  99. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  100. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  101. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  102. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  103. data/lib/evilution/mutator/operator/forwarding_super_to_explicit.rb +71 -0
  104. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  105. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  106. data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
  107. data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
  108. data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
  109. data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
  110. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -0
  111. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  112. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  113. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  114. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  115. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  116. data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
  117. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  118. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  119. data/lib/evilution/mutator/operator/typed_default_return.rb +84 -0
  120. data/lib/evilution/mutator/primitives.rb +25 -0
  121. data/lib/evilution/mutator/registry.rb +32 -1
  122. data/lib/evilution/parallel/pool.rb +1 -0
  123. data/lib/evilution/parallel_db_warning.rb +1 -1
  124. data/lib/evilution/process_supervisor.rb +20 -8
  125. data/lib/evilution/rails_warmup.rb +61 -0
  126. data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
  127. data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
  128. data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
  129. data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
  130. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
  131. data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
  132. data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
  133. data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
  134. data/lib/evilution/reporter/cli/trailer.rb +11 -7
  135. data/lib/evilution/reporter/cli.rb +20 -4
  136. data/lib/evilution/reporter/html/report.rb +1 -0
  137. data/lib/evilution/reporter/json/subjects.rb +29 -0
  138. data/lib/evilution/reporter/json.rb +23 -1
  139. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  140. data/lib/evilution/result/mutation_result.rb +3 -2
  141. data/lib/evilution/result/neutral_reason.rb +35 -0
  142. data/lib/evilution/result/subject_score.rb +28 -0
  143. data/lib/evilution/result/subject_scorer.rb +38 -0
  144. data/lib/evilution/result/summary.rb +44 -2
  145. data/lib/evilution/runner/baseline_runner.rb +17 -7
  146. data/lib/evilution/runner/canary.rb +52 -5
  147. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  148. data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
  149. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +23 -10
  150. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
  151. data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
  152. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
  153. data/lib/evilution/runner/mutation_executor.rb +32 -4
  154. data/lib/evilution/runner/report_publisher.rb +31 -9
  155. data/lib/evilution/runner/target_spec_audit.rb +43 -0
  156. data/lib/evilution/runner.rb +10 -1
  157. data/lib/evilution/source_ast_cache.rb +1 -1
  158. data/lib/evilution/spec_ast_cache.rb +1 -1
  159. data/lib/evilution/version.rb +1 -1
  160. data/lib/evilution.rb +35 -0
  161. metadata +50 -2
@@ -12,22 +12,47 @@ class Evilution::Mutator::Operator::ComparisonReplacement < Evilution::Mutator::
12
12
  :!= => [:==]
13
13
  }.freeze
14
14
 
15
+ # Operators rewritten into a method call, since the method cannot be
16
+ # swapped in as an operator (`a eql? b` does not parse). Ordering
17
+ # comparisons collapse to the stricter equalities — `a < b` becomes
18
+ # `a.eql?(b)` and `a.equal?(b)` — and `a =~ b` becomes `a.match?(b)`,
19
+ # which returns a boolean and leaves `$~` / `$1` unset.
20
+ EQUALITY_METHODS = %i[eql? equal?].freeze
21
+ private_constant :EQUALITY_METHODS
22
+
23
+ METHOD_REPLACEMENTS = {
24
+ :< => EQUALITY_METHODS,
25
+ :<= => EQUALITY_METHODS,
26
+ :> => EQUALITY_METHODS,
27
+ :>= => EQUALITY_METHODS,
28
+ :=~ => [:match?]
29
+ }.freeze
30
+ private_constant :METHOD_REPLACEMENTS
31
+
15
32
  def visit_call_node(node)
16
- replacements = REPLACEMENTS[node.name]
17
- return super unless replacements
33
+ replace_operator(node)
34
+ replace_with_methods(node)
35
+ super
36
+ end
37
+
38
+ private
18
39
 
40
+ def replace_operator(node)
19
41
  loc = node.message_loc
20
- return super unless loc
21
-
22
- replacements.each do |replacement|
23
- add_mutation(
24
- offset: loc.start_offset,
25
- length: loc.length,
26
- replacement: replacement.to_s,
27
- node: node
28
- )
42
+
43
+ REPLACEMENTS.fetch(node.name, []).each do |replacement|
44
+ add_mutation(offset: loc.start_offset, length: loc.length, replacement: replacement.to_s, node: node)
29
45
  end
46
+ end
30
47
 
31
- super
48
+ def replace_with_methods(node)
49
+ methods = METHOD_REPLACEMENTS[node.name]
50
+ return unless methods && (node.arguments in Prism::ArgumentsNode[arguments: [argument]])
51
+
52
+ left = receiver_source(node.receiver)
53
+ methods.each do |method|
54
+ add_mutation(offset: node.start_offset, length: node.location.length,
55
+ replacement: "#{left}.#{method}(#{source_of(argument)})", node: node)
56
+ end
32
57
  end
33
58
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Resolve a dynamic constant lookup with a literal name into a constant path:
6
+ # `Registry.const_get(:Handler)` becomes `Registry::Handler`. A literal
7
+ # inherit flag is dropped; a receiverless call resolves against `self`.
8
+ #
9
+ # Both forms find the same own, inherited and mixed-in constants and raise the
10
+ # same NameError for a missing one. They differ on top-level constants (found
11
+ # by `const_get` through Object, rejected by `::`), on private constants
12
+ # (returned by `const_get` only) and with `inherit = false` (ancestors skipped
13
+ # by `const_get`, searched by `::`). A survivor means none of those paths is
14
+ # exercised, so the dynamic lookup may not be needed.
15
+ #
16
+ # Safe navigation is skipped: a constant path has no nil-safe form.
17
+ class Evilution::Mutator::Operator::ConstGetToConstantPath < Evilution::Mutator::Base
18
+ CONSTANT_NAME = /\A[A-Z]\w*\z/
19
+ private_constant :CONSTANT_NAME
20
+
21
+ # `nil` behaves like `false`.
22
+ INHERIT_FLAGS = [Prism::TrueNode, Prism::FalseNode, Prism::NilNode].freeze
23
+ private_constant :INHERIT_FLAGS
24
+
25
+ def visit_call_node(node)
26
+ rewrite(node) if node.name == :const_get && node.block.nil? && !node.safe_navigation?
27
+ super
28
+ end
29
+
30
+ private
31
+
32
+ def rewrite(node)
33
+ name = constant_name(node)
34
+ return if name.nil?
35
+
36
+ receiver = node.receiver ? source_of(node.receiver) : "self"
37
+ add_mutation(offset: node.start_offset, length: node.location.length, replacement: "#{receiver}::#{name}", node: node)
38
+ end
39
+
40
+ def constant_name(node)
41
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [Prism::SymbolNode => symbol, *flags]]
42
+ return unless flags.empty? || literal_inherit_flag?(flags)
43
+
44
+ symbol.unescaped if symbol.unescaped.match?(CONSTANT_NAME)
45
+ end
46
+
47
+ def literal_inherit_flag?(flags)
48
+ flags.length == 1 && INHERIT_FLAGS.any? { |type| flags.first.is_a?(type) }
49
+ end
50
+ end
@@ -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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Give a forwarding `super` an empty argument list: `def f(a); super; end`
8
+ # becomes `def f(a); super(); end`.
9
+ #
10
+ # Bare `super` hands the parent whatever the method was called with; `super()`
11
+ # hands it nothing. A survivor means the forwarded arguments never reach an
12
+ # assertion — the parent ignores them, or nothing exercises what it does with
13
+ # them. ZsuperRemoval only turns the same node into `nil`, which asks whether
14
+ # the call happens rather than what it carries, and ExplicitSuperMutation works
15
+ # the other direction, on a super that already lists its arguments.
16
+ #
17
+ # The enclosing method must declare something to forward. With no parameters
18
+ # the two forms are identical, and so they are with only a block parameter:
19
+ # `super()` still passes the block along, so nothing would change.
20
+ class Evilution::Mutator::Operator::ForwardingSuperToExplicit < Evilution::Mutator::Base
21
+ KEYWORD_LENGTH = "super".length
22
+ private_constant :KEYWORD_LENGTH
23
+
24
+ def visit_def_node(node)
25
+ enclosing_methods.push(node)
26
+ super
27
+ ensure
28
+ enclosing_methods.pop
29
+ end
30
+
31
+ def visit_forwarding_super_node(node)
32
+ add_parentheses(node) if forwards_arguments?
33
+ super
34
+ end
35
+
36
+ private
37
+
38
+ # A stack, so a def nested in another method's body is answered with its own
39
+ # parameters rather than the outer method's.
40
+ def enclosing_methods
41
+ @enclosing_methods ||= []
42
+ end
43
+
44
+ # Traversal always starts at the subject's own def, so a forwarding super is
45
+ # never reached without one on the stack.
46
+ def forwards_arguments?
47
+ parameters = enclosing_methods.last.parameters
48
+ return false if parameters.nil?
49
+
50
+ !only_block_parameter?(parameters)
51
+ end
52
+
53
+ # `&blk` is forwarded either way, so it is not something `super()` drops.
54
+ # Post-required parameters are not checked: a signature only has them after a
55
+ # rest parameter, which the rest check already rejects.
56
+ def only_block_parameter?(parameters)
57
+ parameters.requireds.empty? && parameters.optionals.empty? && parameters.keywords.empty? &&
58
+ parameters.rest.nil? && parameters.keyword_rest.nil?
59
+ end
60
+
61
+ # The parentheses are inserted right after the keyword rather than replacing
62
+ # the node, whose span also covers a block written on the super.
63
+ def add_parentheses(node)
64
+ add_mutation(
65
+ offset: node.location.start_offset + KEYWORD_LENGTH,
66
+ length: 0,
67
+ replacement: "()",
68
+ node: node
69
+ )
70
+ end
71
+ 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,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
@@ -46,9 +46,18 @@ class Evilution::Mutator::Operator::MethodBodyReplacement < Evilution::Mutator::
46
46
  # class has no parent implementation of the method. We emit it only when the
47
47
  # original body already calls super, using that as a heuristic that a super
48
48
  # target is intended in this context.
49
+ #
50
+ # The search stops at a nested def, whose super belongs to that method rather
51
+ # than to this one. Counting it would emit a super replacement for a method
52
+ # that may have no parent to call, and the mutant would raise NoMethodError on
53
+ # contact — a kill that proves nothing (EV-vk1f / GH #1625). A block is not a
54
+ # boundary: `values.each { super }` does call this method's parent.
55
+ # MethodBodyToSuper#calls_super? draws the same line.
49
56
  def body_calls_super?(node)
50
57
  return true if node.is_a?(Prism::SuperNode) || node.is_a?(Prism::ForwardingSuperNode)
51
58
 
52
- node.child_nodes.any? { |child| child && body_calls_super?(child) }
59
+ node.child_nodes.any? do |child|
60
+ child && !child.is_a?(Prism::DefNode) && body_calls_super?(child)
61
+ end
53
62
  end
54
63
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a whole method body with a bare `raise`: `def foo; body; end` becomes
6
+ # `def foo; raise; end`.
7
+ #
8
+ # A survivor means no example ever calls the method on a path it asserts, so
9
+ # the method is either dead or reached only where the suite looks away. Where
10
+ # body-to-nil (MethodBodyReplacement) asks whether the return value matters,
11
+ # this asks the prior question of whether the call happens at all — a method
12
+ # whose result is discarded survives the nil mutation but not this one.
13
+ class Evilution::Mutator::Operator::MethodBodyToRaise < Evilution::Mutator::Base
14
+ def visit_def_node(node)
15
+ replace_body_with_raise(node)
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def replace_body_with_raise(node)
22
+ statements = body_statements(node.body)
23
+ return if statements.nil?
24
+ return if single_raise?(statements)
25
+
26
+ location = statements.location
27
+
28
+ add_mutation(
29
+ offset: location.start_offset,
30
+ length: location.length,
31
+ replacement: "raise",
32
+ node: node
33
+ )
34
+ end
35
+
36
+ # A method-level rescue/ensure (`def foo; stmts; rescue; ...; end`) makes the
37
+ # body a BeginNode whose location spans the entire `def...end`, keyword and
38
+ # matching `end` included. Replacing that range would delete the method
39
+ # framing and leave a bare `raise` at the enclosing scope, so only the leading
40
+ # statements are replaceable. Returns nil for a rescue/ensure-only body, where
41
+ # there are none, and for an empty method, whose body is nil outright.
42
+ def body_statements(body)
43
+ return body unless body.is_a?(Prism::BeginNode)
44
+
45
+ body.statements
46
+ end
47
+
48
+ # A body that already consists of one unconditional raise would mutate to a
49
+ # near-equivalent: `raise NotImplementedError` -> `raise` only changes the
50
+ # error class, which is what RaiseArgumentStrip (#1537) probes. Arguments are
51
+ # not inspected, so a bare raise is skipped on the same rule.
52
+ def single_raise?(statements)
53
+ body = statements.body
54
+ return false unless body.length == 1
55
+
56
+ only = body.first
57
+ only.is_a?(Prism::CallNode) && only.name == :raise && only.receiver.nil?
58
+ end
59
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Replace a whole method body with a bare `super`: `def foo; body; end` becomes
8
+ # `def foo; super; end`.
9
+ #
10
+ # A survivor means the body adds nothing the suite asserts over whatever
11
+ # ancestor implementation it might be overriding. MethodBodyReplacement emits
12
+ # the same replacement, but only for a body that already calls super, so a plain
13
+ # override — the case worth probing — is never reached by it.
14
+ #
15
+ # `super` needs somewhere to go: without one the mutant raises NoMethodError and
16
+ # dies on contact, which scores a kill that proves nothing. Emission is
17
+ # therefore limited to methods whose enclosing scope has a plausible super
18
+ # chain: an explicit superclass, or a mixin in the right position for that kind
19
+ # of method (include/prepend for instance methods, extend for singleton ones).
20
+ # Whether an ancestor actually defines this method name cannot be known from the
21
+ # source, so a class that inherits or mixes in anything qualifies.
22
+ class Evilution::Mutator::Operator::MethodBodyToSuper < Evilution::Mutator::Base
23
+ INSTANCE_MIXINS = %i[include prepend].freeze
24
+ SINGLETON_MIXINS = %i[extend].freeze
25
+
26
+ def visit_def_node(node)
27
+ replace_body_with_super(node)
28
+ super
29
+ end
30
+
31
+ private
32
+
33
+ def replace_body_with_super(node)
34
+ statements = body_statements(node.body)
35
+ return if statements.nil?
36
+ return if calls_super?(statements)
37
+ return unless super_target?(node)
38
+
39
+ location = statements.location
40
+
41
+ add_mutation(
42
+ offset: location.start_offset,
43
+ length: location.length,
44
+ replacement: "super",
45
+ node: node
46
+ )
47
+ end
48
+
49
+ # A method-level rescue/ensure (`def foo; stmts; rescue; ...; end`) makes the
50
+ # body a BeginNode whose location spans the entire `def...end`, keyword and
51
+ # matching `end` included. Replacing that range would delete the method
52
+ # framing, so only the leading statements are replaceable. Returns nil for a
53
+ # rescue/ensure-only body and for an empty method, whose body is nil.
54
+ def body_statements(body)
55
+ return body unless body.is_a?(Prism::BeginNode)
56
+
57
+ body.statements
58
+ end
59
+
60
+ # MethodBodyReplacement owns bodies that already reach for the parent
61
+ # implementation; emitting here would attribute one mutation to two operators.
62
+ #
63
+ # The search stops at a nested def, whose `super` belongs to that method
64
+ # rather than to the body being mutated. A nested class or module would be a
65
+ # boundary too, but Ruby rejects both inside a method body, so a def is the
66
+ # only one reachable here. A block is not a boundary: `values.map { super }`
67
+ # still calls the enclosing method's parent.
68
+ def calls_super?(node)
69
+ return true if node.is_a?(Prism::SuperNode) || node.is_a?(Prism::ForwardingSuperNode)
70
+
71
+ node.child_nodes.any? do |child|
72
+ child && !child.is_a?(Prism::DefNode) && calls_super?(child)
73
+ end
74
+ end
75
+
76
+ # `def self.foo` and a def inside `class << self` both define singleton
77
+ # methods, which inherit through the singleton class: an explicit superclass
78
+ # carries them, `extend` mixes into them, `include`/`prepend` do not.
79
+ def super_target?(def_node)
80
+ scope = enclosing_scope(def_node)
81
+ return false unless scope
82
+
83
+ singleton = def_node.receiver || scope[:singleton]
84
+ return true if scope[:node].is_a?(Prism::ClassNode) && scope[:node].superclass
85
+
86
+ mixins = singleton ? SINGLETON_MIXINS : INSTANCE_MIXINS
87
+ mixin?(scope[:node], mixins)
88
+ end
89
+
90
+ # The innermost class or module containing the def. A `class << self` body is
91
+ # transparent to the search — the mixins that matter live in the class around
92
+ # it — but it marks everything inside as a singleton method.
93
+ def enclosing_scope(def_node)
94
+ tree = self.class.parsed_tree_for(@subject.file_path, @file_source)
95
+ finder = ScopeFinder.new(def_node.location.start_line)
96
+ finder.visit(tree)
97
+ finder.result
98
+ end
99
+
100
+ # A scope is only consulted once a def was found inside it, so its body is
101
+ # never nil here. A bare `include` with nothing to mix in parses with a nil
102
+ # arguments node, which is what separates it from a real mixin call.
103
+ def mixin?(scope_node, names)
104
+ scope_node.body.body.any? do |child|
105
+ child.is_a?(Prism::CallNode) && child.receiver.nil? &&
106
+ names.include?(child.name) && child.arguments
107
+ end
108
+ end
109
+
110
+ # Visitor that reports the innermost class/module enclosing a line, and
111
+ # whether that line sits inside a `class << self` body.
112
+ class ScopeFinder < Prism::Visitor
113
+ def initialize(target_line)
114
+ @target_line = target_line
115
+ @scope = nil
116
+ @singleton = false
117
+ super()
118
+ end
119
+
120
+ # The enclosing scope and the `class << self` flag are collected
121
+ # independently: the class node is visited before the singleton-class body
122
+ # inside it, so a flag read at that point would always be false.
123
+ def result
124
+ return nil unless @scope
125
+
126
+ { node: @scope, singleton: @singleton }
127
+ end
128
+
129
+ def visit_class_node(node)
130
+ @scope = node if covers?(node)
131
+ super
132
+ end
133
+
134
+ def visit_module_node(node)
135
+ @scope = node if covers?(node)
136
+ super
137
+ end
138
+
139
+ def visit_singleton_class_node(node)
140
+ @singleton = true if covers?(node)
141
+ super
142
+ end
143
+
144
+ private
145
+
146
+ def covers?(node)
147
+ @target_line.between?(node.location.start_line, node.location.end_line)
148
+ end
149
+ end
150
+ end