evilution 0.35.0 → 1.1.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.
@@ -3,10 +3,13 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../mutator"
6
+ require_relative "primitives"
6
7
  require_relative "../integration/loading/body_call_neutralizer"
7
8
  require_relative "../ast/heredoc_span"
8
9
 
9
10
  class Evilution::Mutator::Base < Prism::Visitor
11
+ include Evilution::Mutator::Primitives
12
+
10
13
  AffectedSlices = Data.define(:original, :mutated)
11
14
  private_constant :AffectedSlices
12
15
 
@@ -41,7 +44,12 @@ class Evilution::Mutator::Base < Prism::Visitor
41
44
 
42
45
  private
43
46
 
44
- def add_mutation(offset:, length:, replacement:, node:)
47
+ # `skip_unparseable` makes the mutation vanish rather than be recorded when
48
+ # the surgery does not parse. Off by default: the point operators rely on
49
+ # unparseable bytes reaching the reporter as their own bucket. The shared
50
+ # primitives opt in, since a nil-ification or promotion that does not parse
51
+ # carries no signal.
52
+ def add_mutation(offset:, length:, replacement:, node:, skip_unparseable: false)
45
53
  return if @filter && @filter.skip?(node)
46
54
 
47
55
  # When the byte range opens a heredoc but stops at its inline anchor,
@@ -67,6 +75,8 @@ class Evilution::Mutator::Base < Prism::Visitor
67
75
  surgery = Evilution::AST::SourceSurgeon.apply(
68
76
  @file_source, offset: offset, length: length, replacement: replacement
69
77
  )
78
+ return if skip_unparseable && surgery.unparseable?
79
+
70
80
  slices = slice_affected_lines(
71
81
  mutated_source: surgery.source,
72
82
  offset: offset,
@@ -74,7 +84,9 @@ class Evilution::Mutator::Base < Prism::Visitor
74
84
  replacement_bytesize: replacement.bytesize
75
85
  )
76
86
 
77
- @mutations << build_mutation_record(node, surgery, slices)
87
+ mutation = build_mutation_record(node, surgery, slices)
88
+ @mutations << mutation
89
+ mutation
78
90
  end
79
91
 
80
92
  def build_mutation_record(node, surgery, slices)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a whole compound boolean with `nil`: `a && b` becomes `nil`, and
6
+ # the same for `||` and the `and` / `or` keyword forms.
7
+ #
8
+ # Where BooleanOperandPromotion drops one side, this drops both. The
9
+ # survivor it targets is a condition whose value is never asserted — code
10
+ # that runs the expression for its side effects, or a test that only checks
11
+ # the branch was taken rather than what the branch was given.
12
+ class Evilution::Mutator::Operator::BooleanExpressionToNil < Evilution::Mutator::Base
13
+ def visit_and_node(node)
14
+ mutate_to_nil(node)
15
+ super
16
+ end
17
+
18
+ def visit_or_node(node)
19
+ mutate_to_nil(node)
20
+ super
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Drop one side of a compound boolean: `a && b` becomes `a` and `b`, and the
6
+ # same for `||` (both the symbol and the `and` / `or` keyword forms).
7
+ #
8
+ # BooleanOperatorReplacement swaps the operator but always keeps both
9
+ # operands, so a test that only ever exercises one side of a condition
10
+ # survives it. Promoting an operand kills exactly those tests.
11
+ class Evilution::Mutator::Operator::BooleanOperandPromotion < Evilution::Mutator::Base
12
+ def visit_and_node(node)
13
+ promote_operands(node)
14
+ super
15
+ end
16
+
17
+ def visit_or_node(node)
18
+ promote_operands(node)
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ # Nested expressions need no special handling: `a && b && c` is an outer
25
+ # AndNode over an inner one, so the outer visit yields `a && b` and `c`
26
+ # while `super` descends and the inner visit yields `a && c` and `b`.
27
+ def promote_operands(node)
28
+ promote_child(node, node.left)
29
+ promote_child(node, node.right)
30
+ end
31
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Drop one `in` clause from a `case/in`, leaving the rest of the arms in
6
+ # place.
7
+ #
8
+ # Input that used to match the removed pattern now falls through to a later
9
+ # arm, to the `else`, or -- with neither -- raises NoMatchingPatternError. A
10
+ # survivor means the suite never exercises that arm, so its pattern could be
11
+ # deleted outright without any test noticing.
12
+ #
13
+ # Pattern matching had no visitor at all before this operator: the case/in
14
+ # grammar is a CaseMatchNode of InNodes, unrelated to the CaseNode/WhenNode
15
+ # pair CaseWhen handles.
16
+ class Evilution::Mutator::Operator::CaseIn < Evilution::Mutator::Base
17
+ def visit_case_match_node(node)
18
+ remove_in_clauses(node)
19
+ remove_else_branch(node)
20
+ super
21
+ end
22
+
23
+ private
24
+
25
+ # A case/in needs at least one arm to parse, so a lone clause stays put.
26
+ def remove_in_clauses(node)
27
+ return if node.conditions.length < 2
28
+
29
+ node.conditions.each do |in_node|
30
+ location = in_node.location
31
+
32
+ add_mutation(
33
+ offset: location.start_offset,
34
+ length: location.length,
35
+ replacement: "",
36
+ node: in_node
37
+ )
38
+ end
39
+ end
40
+
41
+ # Without an else, an unmatched value raises NoMatchingPatternError rather
42
+ # than falling through, so a survivor means nothing in the suite reaches the
43
+ # fallback.
44
+ #
45
+ # An empty else body is worth removing here, which is where this parts ways
46
+ # with CaseWhen: a case/when yields nil whether its else is empty or absent,
47
+ # but an empty case/in else yields nil while an absent one raises. Prism
48
+ # reports no statements for that shape, so the edit covers the keyword alone.
49
+ def remove_else_branch(node)
50
+ else_clause = node.else_clause
51
+ return if else_clause.nil?
52
+
53
+ keyword_location = else_clause.else_keyword_loc
54
+ statements = else_clause.statements
55
+ end_offset = statements.nil? ? keyword_location.end_offset : statements.location.end_offset
56
+
57
+ add_mutation(
58
+ offset: keyword_location.start_offset,
59
+ length: end_offset - keyword_location.start_offset,
60
+ replacement: "",
61
+ node: else_clause
62
+ )
63
+ end
64
+ end
@@ -5,7 +5,9 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::CaseWhen < Evilution::Mutator::Base
6
6
  def visit_case_node(node)
7
7
  remove_when_branches(node)
8
+ remove_when_conditions(node)
8
9
  replace_when_bodies(node)
10
+ raise_in_empty_when_bodies(node)
9
11
  remove_else_branch(node)
10
12
 
11
13
  super
@@ -26,9 +28,42 @@ class Evilution::Mutator::Operator::CaseWhen < Evilution::Mutator::Base
26
28
  end
27
29
  end
28
30
 
31
+ # `when a, b` matches on either value, so dropping the whole arm cannot tell
32
+ # which of them a test actually exercises. Shortening the list one value at a
33
+ # time can. WhenNode#conditions holds the values; the enclosing CaseNode's
34
+ # own #conditions holds the arms.
35
+ def remove_when_conditions(node)
36
+ node.conditions.each do |when_node|
37
+ values = when_node.conditions
38
+ next if values.length < 2
39
+
40
+ values.each_index do |index|
41
+ offset, length = condition_removal_span(values, index)
42
+
43
+ add_mutation(offset: offset, length: length, replacement: "", node: when_node)
44
+ end
45
+ end
46
+ end
47
+
48
+ # Each value has to take its separating comma with it, or the arm is left
49
+ # with a dangling one. The first value owns the comma that follows it; every
50
+ # later value owns the comma that precedes it. Taking whole byte ranges
51
+ # between value boundaries also sweeps up any newline the list wraps on.
52
+ def condition_removal_span(values, index)
53
+ if index.zero?
54
+ start_offset = values[0].location.start_offset
55
+ end_offset = values[1].location.start_offset
56
+ else
57
+ start_offset = values[index - 1].location.end_offset
58
+ end_offset = values[index].location.end_offset
59
+ end
60
+
61
+ [start_offset, end_offset - start_offset]
62
+ end
63
+
29
64
  def replace_when_bodies(node)
30
65
  node.conditions.each do |when_node|
31
- next if when_node.statements.nil? || when_node.statements.body.empty?
66
+ next if empty_body?(when_node)
32
67
 
33
68
  add_mutation(
34
69
  offset: when_node.statements.location.start_offset,
@@ -39,6 +74,42 @@ class Evilution::Mutator::Operator::CaseWhen < Evilution::Mutator::Base
39
74
  end
40
75
  end
41
76
 
77
+ # An empty arm is a deliberate no-op, so there is no body to blank out --
78
+ # dropping the arm entirely is indistinguishable from falling through to a
79
+ # missing else. Inserting a raise is what proves the arm was selected.
80
+ def raise_in_empty_when_bodies(node)
81
+ node.conditions.each do |when_node|
82
+ next unless empty_body?(when_node)
83
+
84
+ indent = " " * (indentation_of(when_node.keyword_loc.start_offset) + 2)
85
+
86
+ add_mutation(
87
+ offset: body_insert_offset(when_node),
88
+ length: 0,
89
+ replacement: "\n#{indent}raise",
90
+ node: when_node
91
+ )
92
+ end
93
+ end
94
+
95
+ def empty_body?(when_node)
96
+ statements = when_node.statements
97
+ statements.nil? || statements.body.empty?
98
+ end
99
+
100
+ # `when 1, 2` puts the body after the last condition; `when 1 then` after the
101
+ # keyword. Prism reports a nil then_keyword_loc when the form omits it.
102
+ def body_insert_offset(when_node)
103
+ then_keyword_loc = when_node.then_keyword_loc
104
+ location = then_keyword_loc.nil? ? when_node.conditions.last.location : then_keyword_loc
105
+
106
+ location.start_offset + location.length
107
+ end
108
+
109
+ def indentation_of(offset)
110
+ offset - line_start_byte(@file_source, offset)
111
+ end
112
+
42
113
  def remove_else_branch(node)
43
114
  else_clause = node.else_clause
44
115
  return if else_clause.nil? || else_clause.statements.nil?
@@ -4,21 +4,34 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::ConditionalBranch < Evilution::Mutator::Base
6
6
  def visit_if_node(node)
7
- return super unless node.statements
7
+ blank_branches(node, node.subsequent)
8
8
 
9
- add_nil_mutation(node.statements, node)
10
- add_nil_mutation_to_else(node.subsequent, node)
9
+ super
10
+ end
11
+
12
+ # Prism gives `unless` its own node type, and names the else slot
13
+ # `else_clause` rather than IfNode's `subsequent`. Everything below that is
14
+ # the same shape, so both forms share the branch handling.
15
+ def visit_unless_node(node)
16
+ blank_branches(node, node.else_clause)
11
17
 
12
18
  super
13
19
  end
14
20
 
15
21
  private
16
22
 
17
- def add_nil_mutation_to_else(subsequent, node)
18
- return unless subsequent.is_a?(Prism::ElseNode)
19
- return if subsequent.statements.nil?
23
+ def blank_branches(node, else_clause)
24
+ return if node.statements.nil?
25
+
26
+ add_nil_mutation(node.statements, node)
27
+ add_nil_mutation_to_else(else_clause, node)
28
+ end
29
+
30
+ def add_nil_mutation_to_else(else_clause, node)
31
+ return unless else_clause.is_a?(Prism::ElseNode)
32
+ return if else_clause.statements.nil?
20
33
 
21
- add_nil_mutation(subsequent.statements, node)
34
+ add_nil_mutation(else_clause.statements, node)
22
35
  end
23
36
 
24
37
  def add_nil_mutation(statements, node)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace the if-branch with the else body and drop the else:
6
+ # `if c; x; else; y; end` becomes `if c; y; end`.
7
+ #
8
+ # The condition still runs and still selects, but both of its outcomes
9
+ # change: truthy now yields the else body, falsy now yields nil. That is out
10
+ # of reach of the existing conditional operators — ConditionalNegation
11
+ # pins the predicate to one branch, ConditionalBranch blanks a body to nil —
12
+ # so this survives only when the suite never distinguishes the two branch
13
+ # values from each other.
14
+ class Evilution::Mutator::Operator::IfBranchSwap < Evilution::Mutator::Base
15
+ def visit_if_node(node)
16
+ swap_branches(node)
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ # The edit spans from the start of the if-branch to the end of the else
23
+ # body, which swallows the `else` keyword along with the original branch.
24
+ # It deliberately stops short of ElseNode#location, which runs on to the
25
+ # closing `end` the mutation needs to keep.
26
+ #
27
+ # `subsequent` is an IfNode rather than an ElseNode on an `elsif`, and
28
+ # nil on a bare `if`; neither has an else body to promote. A ternary does
29
+ # reach the edit, but `c ? y` does not parse, so add_mutation drops it.
30
+ def swap_branches(node)
31
+ else_node = node.subsequent
32
+ return unless else_node.is_a?(Prism::ElseNode)
33
+
34
+ then_statements = node.statements
35
+ else_statements = else_node.statements
36
+ return if then_statements.nil? || else_statements.nil?
37
+
38
+ offset = then_statements.location.start_offset
39
+
40
+ add_mutation(
41
+ offset: offset,
42
+ length: else_statements.location.end_offset - offset,
43
+ replacement: source_of(else_statements),
44
+ node: node,
45
+ skip_unparseable: true
46
+ )
47
+ end
48
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a loop body with a bare `raise`: `while c; body; end` becomes
6
+ # `while c; raise; end`.
7
+ #
8
+ # A survivor means no test ever enters the loop, so the body is either dead
9
+ # or reached only by paths the suite never exercises. The raise terminates
10
+ # the loop on its first iteration, which is what makes this safe where
11
+ # body-to-nil is not: nil-ing a body that advances the predicate spins until
12
+ # the per-mutation timeout (EV-170m.7).
13
+ class Evilution::Mutator::Operator::LoopBodyToRaise < Evilution::Mutator::Base
14
+ def visit_while_node(node)
15
+ replace_body_with_raise(node)
16
+ super
17
+ end
18
+
19
+ def visit_until_node(node)
20
+ replace_body_with_raise(node)
21
+ super
22
+ end
23
+
24
+ private
25
+
26
+ def replace_body_with_raise(node)
27
+ statements = body_statements(node)
28
+ return if statements.nil?
29
+ return if bare_raise?(statements)
30
+
31
+ location = statements.location
32
+
33
+ add_mutation(
34
+ offset: location.start_offset,
35
+ length: location.length,
36
+ replacement: "raise",
37
+ node: node
38
+ )
39
+ end
40
+
41
+ # A post-form loop (`begin ... end while c`) hangs its body off a BeginNode,
42
+ # and that node's own span is what WhileNode#statements reports. Editing the
43
+ # outer span would rewrite the loop as `raise while c`, which checks the
44
+ # predicate first and so no longer runs the body unconditionally -- exactly
45
+ # the property this mutation exists to probe. Reach through to the inner
46
+ # statements instead and leave `begin ... end while c` standing.
47
+ #
48
+ # begin_modifier? is set only for that post-form, where the grammar
49
+ # guarantees the single wrapped statement is the BeginNode.
50
+ def body_statements(node)
51
+ statements = node.statements
52
+ return statements unless node.begin_modifier?
53
+
54
+ statements.body.first.statements
55
+ end
56
+
57
+ # A body that already raises unconditionally would mutate to itself.
58
+ def bare_raise?(statements)
59
+ body = statements.body
60
+ return false unless body.length == 1
61
+
62
+ only = body.first
63
+ only.is_a?(Prism::CallNode) &&
64
+ only.name == :raise &&
65
+ only.arguments.nil? &&
66
+ only.receiver.nil?
67
+ end
68
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a one-line pattern match with `false`: `x in Integer` becomes
6
+ # `false`.
7
+ #
8
+ # The mutant differs from the original only on inputs the pattern actually
9
+ # matches, so a survivor means no test ever feeds it a matching value -- the
10
+ # true path of the predicate is unexercised.
11
+ #
12
+ # Only Prism::MatchPredicateNode is mutated. Its sibling MatchRequiredNode
13
+ # (`x => Integer`) raises NoMatchingPatternError rather than returning a
14
+ # boolean, so `false` would not stand in for it.
15
+ class Evilution::Mutator::Operator::PatternPredicate < Evilution::Mutator::Base
16
+ def visit_match_predicate_node(node)
17
+ location = node.location
18
+
19
+ add_mutation(
20
+ offset: location.start_offset,
21
+ length: location.length,
22
+ replacement: "false",
23
+ node: node
24
+ )
25
+
26
+ super
27
+ end
28
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutator"
4
+
5
+ # Mutation shapes shared across the operator families: replacing an
6
+ # expression with `nil`, and replacing an expression with the source of one
7
+ # of its children. Both are built on Base#add_mutation, so the
8
+ # equivalent-mutant filter and the heredoc-span guards still apply.
9
+ #
10
+ # Unlike a bare add_mutation call, these skip rather than emit when the
11
+ # result would not parse in its surrounding context. A promotion that does
12
+ # not parse is noise rather than signal, so it never reaches the
13
+ # `unparseable` bucket the point operators still populate.
14
+ module Evilution::Mutator::Primitives
15
+ private
16
+
17
+ # Replace `target`'s byte span with `nil`, attributing the mutation to
18
+ # `node`. `target` defaults to `node`; pass an inner node to nil out a body
19
+ # while keeping the reported location on the enclosing construct.
20
+ def mutate_to_nil(node, target: node)
21
+ replace_span(node: node, target: target, replacement: "nil")
22
+ end
23
+
24
+ # Replace `target`'s byte span with `child`'s source, verbatim. `child` may
25
+ # be nil — Prism leaves optional slots (a call's receiver, an if's else)
26
+ # empty — in which case there is nothing to promote.
27
+ def promote_child(node, child, target: node)
28
+ return nil if child.nil?
29
+
30
+ replace_span(node: node, target: target, replacement: source_of(child))
31
+ end
32
+
33
+ def replace_span(node:, target:, replacement:)
34
+ return nil if target.nil?
35
+
36
+ location = target.location
37
+ return nil if replacement == byteslice_source(location.start_offset, location.length)
38
+
39
+ add_mutation(
40
+ offset: location.start_offset,
41
+ length: location.length,
42
+ replacement: replacement,
43
+ node: node,
44
+ skip_unparseable: true
45
+ )
46
+ end
47
+
48
+ def source_of(child)
49
+ location = child.location
50
+ byteslice_source(location.start_offset, location.length)
51
+ end
52
+ end
@@ -29,6 +29,8 @@ class Evilution::Mutator::Registry
29
29
  Evilution::Mutator::Operator::ComparisonReplacement,
30
30
  Evilution::Mutator::Operator::ArithmeticReplacement,
31
31
  Evilution::Mutator::Operator::BooleanOperatorReplacement,
32
+ Evilution::Mutator::Operator::BooleanOperandPromotion,
33
+ Evilution::Mutator::Operator::BooleanExpressionToNil,
32
34
  Evilution::Mutator::Operator::BooleanLiteralReplacement,
33
35
  Evilution::Mutator::Operator::NilReplacement,
34
36
  Evilution::Mutator::Operator::IntegerLiteral,
@@ -39,6 +41,7 @@ class Evilution::Mutator::Registry
39
41
  Evilution::Mutator::Operator::SymbolLiteral,
40
42
  Evilution::Mutator::Operator::ConditionalNegation,
41
43
  Evilution::Mutator::Operator::ConditionalBranch,
44
+ Evilution::Mutator::Operator::IfBranchSwap,
42
45
  Evilution::Mutator::Operator::LastExpressionRemoval,
43
46
  Evilution::Mutator::Operator::StatementDeletion,
44
47
  Evilution::Mutator::Operator::MethodBodyReplacement,
@@ -92,9 +95,12 @@ class Evilution::Mutator::Registry
92
95
  Evilution::Mutator::Operator::DefinedCheck,
93
96
  Evilution::Mutator::Operator::RegexCapture,
94
97
  Evilution::Mutator::Operator::LoopFlip,
98
+ Evilution::Mutator::Operator::LoopBodyToRaise,
95
99
  Evilution::Mutator::Operator::StringInterpolation,
96
100
  Evilution::Mutator::Operator::RetryRemoval,
97
101
  Evilution::Mutator::Operator::CaseWhen,
102
+ Evilution::Mutator::Operator::CaseIn,
103
+ Evilution::Mutator::Operator::PatternPredicate,
98
104
  Evilution::Mutator::Operator::PredicateReplacement,
99
105
  Evilution::Mutator::Operator::EqualityToIdentity,
100
106
  Evilution::Mutator::Operator::LambdaBody,
@@ -31,14 +31,12 @@ class Evilution::RelatedSpecHeuristic
31
31
  def extract_domain(file_path)
32
32
  normalized = normalize_path(file_path)
33
33
 
34
- # Strip common prefixes and get the relative path under app/ or lib/
35
34
  relative = normalized
36
35
  .delete_prefix("app/controllers/")
37
36
  .delete_prefix("app/models/")
38
37
  .delete_prefix("app/")
39
38
  .delete_prefix("lib/")
40
39
 
41
- # Remove .rb extension and _controller suffix
42
40
  basename = relative.sub(/\.rb\z/, "")
43
41
  basename = basename.sub(/_controller\z/, "")
44
42
 
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require_relative "../evilution"
5
5
 
6
+ # @api private
6
7
  class Evilution::Runner
7
8
  # Autoloaded: canary.rb subclasses Evilution::Error at class-body eval
8
9
  # time, which is not yet defined while evilution.rb is mid-load. Deferring
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.35.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -27,11 +27,14 @@ require_relative "evilution/mutator/operator/integer_literal"
27
27
  require_relative "evilution/mutator/operator/float_literal"
28
28
  require_relative "evilution/mutator/operator/nil_replacement"
29
29
  require_relative "evilution/mutator/operator/boolean_operator_replacement"
30
+ require_relative "evilution/mutator/operator/boolean_operand_promotion"
31
+ require_relative "evilution/mutator/operator/boolean_expression_to_nil"
30
32
  require_relative "evilution/mutator/operator/arithmetic_replacement"
31
33
  require_relative "evilution/mutator/operator/string_literal"
32
34
  require_relative "evilution/mutator/operator/array_literal"
33
35
  require_relative "evilution/mutator/operator/hash_literal"
34
36
  require_relative "evilution/mutator/operator/conditional_branch"
37
+ require_relative "evilution/mutator/operator/if_branch_swap"
35
38
  require_relative "evilution/mutator/operator/symbol_literal"
36
39
  require_relative "evilution/mutator/operator/conditional_negation"
37
40
  require_relative "evilution/mutator/operator/negation_insertion"
@@ -85,9 +88,12 @@ require_relative "evilution/mutator/operator/splat_operator"
85
88
  require_relative "evilution/mutator/operator/defined_check"
86
89
  require_relative "evilution/mutator/operator/regex_capture"
87
90
  require_relative "evilution/mutator/operator/loop_flip"
91
+ require_relative "evilution/mutator/operator/loop_body_to_raise"
88
92
  require_relative "evilution/mutator/operator/string_interpolation"
89
93
  require_relative "evilution/mutator/operator/retry_removal"
90
94
  require_relative "evilution/mutator/operator/case_when"
95
+ require_relative "evilution/mutator/operator/case_in"
96
+ require_relative "evilution/mutator/operator/pattern_predicate"
91
97
  require_relative "evilution/mutator/operator/predicate_replacement"
92
98
  require_relative "evilution/mutator/operator/predicate_to_nil"
93
99
  require_relative "evilution/mutator/operator/equality_to_identity"
@@ -129,6 +135,12 @@ require_relative "evilution/cli"
129
135
  require_relative "evilution/disable_comment"
130
136
  require_relative "evilution/runner"
131
137
 
138
+ # Evilution has no public Ruby API. The entire `Evilution::` namespace is
139
+ # internal and may change in any release, including patches. The stable,
140
+ # SemVer-governed contract is the CLI, `.evilution.yml` config keys, session
141
+ # JSON files, the MCP tool schemas, and process exit codes. See
142
+ # docs/public_api.md and docs/versioning.md.
143
+ # @api private
132
144
  module Evilution
133
145
  # Captured at load time, before any isolator can chdir into a per-mutation
134
146
  # sandbox. Used as the anchor for resolving project-relative paths (spec
@@ -128,8 +128,10 @@ module CompareTargeting
128
128
  def lost_kills(mode = "coverage")
129
129
  full = @modes.fetch("full_file")
130
130
  other = @modes.fetch(mode)
131
- full.keys.select { |key| full.status(key) == "killed" && other.status(key) != "killed" }
132
- .map { |key| full.label(key) }
131
+ full
132
+ .keys
133
+ .select { |key| full.status(key) == "killed" && other.status(key) != "killed" }
134
+ .map { |key| full.label(key) }
133
135
  end
134
136
 
135
137
  def to_row(repo)