evilution 1.0.0 → 1.2.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +41 -0
  3. data/CHANGELOG.md +65 -0
  4. data/README.md +149 -13
  5. data/docs/architecture.md +20 -5
  6. data/docs/isolation.md +3 -4
  7. data/exe/evil +8 -1
  8. data/exe/evilution +8 -1
  9. data/lib/evilution/ast/local_reads.rb +43 -0
  10. data/lib/evilution/cli/exit_guard.rb +59 -0
  11. data/lib/evilution/cli/parser/options_builder.rb +1 -0
  12. data/lib/evilution/cli.rb +1 -0
  13. data/lib/evilution/config.rb +3 -2
  14. data/lib/evilution/diagnostic.rb +22 -0
  15. data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
  16. data/lib/evilution/integration/loading/redefinition_recovery.rb +1 -1
  17. data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
  18. data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
  19. data/lib/evilution/integration/minitest.rb +2 -1
  20. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
  21. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +4 -1
  22. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
  23. data/lib/evilution/integration/rspec.rb +48 -2
  24. data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
  25. data/lib/evilution/isolation/fork.rb +29 -8
  26. data/lib/evilution/mcp/complete_result_server.rb +41 -0
  27. data/lib/evilution/mcp/server.rb +2 -1
  28. data/lib/evilution/mutator/base.rb +14 -2
  29. data/lib/evilution/mutator/operator/block_destructuring_expansion.rb +85 -0
  30. data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -0
  31. data/lib/evilution/mutator/operator/boolean_expression_to_nil.rb +22 -0
  32. data/lib/evilution/mutator/operator/boolean_operand_promotion.rb +31 -0
  33. data/lib/evilution/mutator/operator/case_in.rb +64 -0
  34. data/lib/evilution/mutator/operator/case_when.rb +72 -1
  35. data/lib/evilution/mutator/operator/conditional_branch.rb +20 -7
  36. data/lib/evilution/mutator/operator/forwarding_super_to_explicit.rb +71 -0
  37. data/lib/evilution/mutator/operator/if_branch_swap.rb +48 -0
  38. data/lib/evilution/mutator/operator/loop_body_to_raise.rb +68 -0
  39. data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
  40. data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
  41. data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
  42. data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
  43. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -0
  44. data/lib/evilution/mutator/operator/pattern_predicate.rb +28 -0
  45. data/lib/evilution/mutator/operator/typed_default_return.rb +84 -0
  46. data/lib/evilution/mutator/primitives.rb +52 -0
  47. data/lib/evilution/mutator/registry.rb +14 -0
  48. data/lib/evilution/process_supervisor.rb +20 -8
  49. data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
  50. data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
  51. data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
  52. data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
  53. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
  54. data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
  55. data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
  56. data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
  57. data/lib/evilution/reporter/cli/trailer.rb +11 -7
  58. data/lib/evilution/reporter/cli.rb +20 -4
  59. data/lib/evilution/reporter/json/subjects.rb +29 -0
  60. data/lib/evilution/reporter/json.rb +23 -1
  61. data/lib/evilution/result/mutation_result.rb +3 -2
  62. data/lib/evilution/result/neutral_reason.rb +35 -0
  63. data/lib/evilution/result/subject_score.rb +28 -0
  64. data/lib/evilution/result/subject_scorer.rb +37 -0
  65. data/lib/evilution/result/summary.rb +44 -2
  66. data/lib/evilution/runner/canary.rb +52 -5
  67. data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
  68. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +9 -3
  69. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
  70. data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
  71. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
  72. data/lib/evilution/runner/mutation_executor.rb +32 -4
  73. data/lib/evilution/runner/report_publisher.rb +31 -9
  74. data/lib/evilution/runner/target_spec_audit.rb +43 -0
  75. data/lib/evilution/runner.rb +10 -1
  76. data/lib/evilution/version.rb +1 -1
  77. data/lib/evilution.rb +18 -0
  78. data/scripts/compare_targeting +4 -2
  79. metadata +33 -2
@@ -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
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Reassign an optional parameter to its own default at the top of the body:
8
+ # `def f(a = 1); body; end` becomes `def f(a = 1); a = 1; body; end`.
9
+ #
10
+ # Whatever a caller passed is overwritten, so the mutant survives only where
11
+ # every example already passes the default — or passes a value the assertions
12
+ # never distinguish from it. Where OptionalParameterToRequired asks whether the
13
+ # default is ever used, this asks the opposite question: whether any other value
14
+ # ever is.
15
+ #
16
+ # The parameter must be read somewhere in the body, otherwise overwriting it
17
+ # changes nothing and the mutant would survive every suite. An endless method is
18
+ # left alone: its body is a single expression, and putting a second statement in
19
+ # front of it would change what the method returns rather than what it computes.
20
+ # KeywordArgument owns optional keyword parameters, and a block's optional
21
+ # parameter is left alone for the same reason it is elsewhere — a block ignores
22
+ # arity, so the default is not a contract.
23
+ class Evilution::Mutator::Operator::OptionalDefaultInjection < Evilution::Mutator::Base
24
+ def visit_def_node(node)
25
+ inject_defaults(node)
26
+ super
27
+ end
28
+
29
+ private
30
+
31
+ def inject_defaults(node)
32
+ return if node.equal_loc
33
+ return unless node.parameters
34
+
35
+ statements = body_statements(node.body)
36
+ return if statements.nil?
37
+
38
+ node.parameters.optionals.each { |optional| inject_default(optional, node.body, statements) }
39
+ end
40
+
41
+ # The read is looked for across the whole body, rescue and ensure clauses
42
+ # included, since a parameter read only in a rescue clause is still read. The
43
+ # injection itself goes ahead of the leading statements, which is the only
44
+ # place a statement can be added.
45
+ def inject_default(optional, body, statements)
46
+ name = optional.name.to_s
47
+ return if name.start_with?("_")
48
+ return unless Evilution::AST::LocalReads.new.call(body, name)
49
+
50
+ first = statements.body.first.location
51
+
52
+ add_mutation(
53
+ offset: first.start_offset,
54
+ length: 0,
55
+ replacement: "#{source_of(optional)}; ",
56
+ node: optional
57
+ )
58
+ end
59
+
60
+ # A method-level rescue/ensure (`def foo; stmts; rescue; ...; end`) makes the
61
+ # body a BeginNode whose location spans the entire `def...end`. The injection
62
+ # belongs ahead of the leading statements; returns nil for a rescue/ensure-only
63
+ # body and for an empty method, whose body is nil. A body that holds no
64
+ # statements is always nil rather than an empty StatementsNode, so a present
65
+ # node always has a first statement to inject ahead of.
66
+ def body_statements(body)
67
+ return body unless body.is_a?(Prism::BeginNode)
68
+
69
+ body.statements
70
+ end
71
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Drop the default of an optional positional parameter, making it required:
8
+ # `def f(a = 1)` becomes `def f(a)`.
9
+ #
10
+ # A survivor means no example calls the method without that argument, so the
11
+ # default is never exercised and the value it supplies is unasserted. Callers
12
+ # that pass the argument are unaffected, which is what separates this from a
13
+ # mutation that breaks every call site.
14
+ #
15
+ # Ruby allows a required parameter to follow an optional one, so each optional
16
+ # is mutated on its own and the rest of the signature stays as written.
17
+ # KeywordArgument owns optional keyword parameters. Blocks are left alone: a
18
+ # block ignores arity, so a missing argument arrives as nil rather than raising,
19
+ # and making its parameter required would change nothing.
20
+ class Evilution::Mutator::Operator::OptionalParameterToRequired < Evilution::Mutator::Base
21
+ def visit_def_node(node)
22
+ parameters = node.parameters
23
+ parameters.optionals.each { |optional| require_parameter(optional) } if parameters
24
+
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def require_parameter(node)
31
+ location = node.location
32
+ name_loc = node.name_loc
33
+
34
+ add_mutation(
35
+ offset: location.start_offset,
36
+ length: location.length,
37
+ replacement: byteslice_source(name_loc.start_offset, name_loc.length),
38
+ node: node
39
+ )
40
+ end
41
+ 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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Replace a single-expression method body with the empty value of the type it
8
+ # returns: `def names(users); users.map(&:name); end` becomes
9
+ # `def names(users); []; end`.
10
+ #
11
+ # A survivor means the suite asserts the shape of the returned value but never
12
+ # its content — a test that only checks `be_an(Array)`, or that calls the method
13
+ # for its side effects and ignores what comes back.
14
+ #
15
+ # CollectionReturn and ScalarReturn do this for bodies of two or more
16
+ # statements, and the literal operators (array_literal, hash_literal,
17
+ # string_literal, integer_literal, float_literal) cover a body that is itself a
18
+ # literal. What neither reaches is the single-expression body whose type is not
19
+ # written down — the common shape of readers and builders — so the type is taken
20
+ # from the trailing call's selector instead.
21
+ #
22
+ # Only selectors whose return type is fixed by Ruby (or a widely-used extension with a stable contract,
23
+ # e.g. ActiveSupport's `index_by`) are listed.
24
+ # A method of the same name defined elsewhere can still return something else,
25
+ # in which case the mutant raises where it is consumed and dies on contact; the
26
+ # table is kept narrow to make that rare.
27
+ class Evilution::Mutator::Operator::TypedDefaultReturn < Evilution::Mutator::Base
28
+ SELECTOR_TYPES = {
29
+ "[]" => %i[map collect select filter reject sort sort_by to_a compact flatten values keys uniq],
30
+ "{}" => %i[to_h group_by tally index_by transform_values transform_keys],
31
+ "0" => %i[count size length],
32
+ '""' => %i[to_s join upcase downcase strip]
33
+ }.freeze
34
+
35
+ REPLACEMENT_BY_SELECTOR = SELECTOR_TYPES.flat_map do |replacement, selectors|
36
+ selectors.map { |selector| [selector, replacement] }
37
+ end.to_h.freeze
38
+ private_constant :REPLACEMENT_BY_SELECTOR
39
+
40
+ def visit_def_node(node)
41
+ replace_body_with_default(node)
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ def replace_body_with_default(node)
48
+ statements = body_statements(node.body)
49
+ return if statements.nil?
50
+ return unless statements.body.length == 1
51
+
52
+ replacement = default_for(statements.body.first)
53
+ return if replacement.nil?
54
+
55
+ location = statements.location
56
+
57
+ add_mutation(
58
+ offset: location.start_offset,
59
+ length: location.length,
60
+ replacement: replacement,
61
+ node: node
62
+ )
63
+ end
64
+
65
+ # A method-level rescue/ensure (`def foo; stmts; rescue; ...; end`) makes the
66
+ # body a BeginNode whose location spans the entire `def...end`, keyword and
67
+ # matching `end` included. Replacing that range would delete the method
68
+ # framing, so only the leading statements are replaceable. Returns nil for a
69
+ # rescue/ensure-only body and for an empty method, whose body is nil.
70
+ def body_statements(body)
71
+ return body unless body.is_a?(Prism::BeginNode)
72
+
73
+ body.statements
74
+ end
75
+
76
+ # A body that is already a literal is left to the literal operators, which
77
+ # emit the same empty value for it.
78
+ def default_for(node)
79
+ case node
80
+ when Prism::InterpolatedStringNode then '""'
81
+ when Prism::CallNode then REPLACEMENT_BY_SELECTOR[node.name]
82
+ end
83
+ end
84
+ 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,20 @@ 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,
99
+ Evilution::Mutator::Operator::MethodBodyToRaise,
100
+ Evilution::Mutator::Operator::MethodBodyToSuper,
101
+ Evilution::Mutator::Operator::TypedDefaultReturn,
102
+ Evilution::Mutator::Operator::BlockParameterDrop,
103
+ Evilution::Mutator::Operator::OptionalParameterToRequired,
104
+ Evilution::Mutator::Operator::OptionalDefaultInjection,
105
+ Evilution::Mutator::Operator::BlockDestructuringExpansion,
106
+ Evilution::Mutator::Operator::ForwardingSuperToExplicit,
95
107
  Evilution::Mutator::Operator::StringInterpolation,
96
108
  Evilution::Mutator::Operator::RetryRemoval,
97
109
  Evilution::Mutator::Operator::CaseWhen,
110
+ Evilution::Mutator::Operator::CaseIn,
111
+ Evilution::Mutator::Operator::PatternPredicate,
98
112
  Evilution::Mutator::Operator::PredicateReplacement,
99
113
  Evilution::Mutator::Operator::EqualityToIdentity,
100
114
  Evilution::Mutator::Operator::LambdaBody,
@@ -30,7 +30,9 @@ class Evilution::ProcessSupervisor
30
30
  # One tracked child: leader pid, its process-group id (== pid for a group
31
31
  # leader), the parent-side fds to close on reap, and an optional sandbox dir
32
32
  # to remove on reap.
33
- Handle = Struct.new(:pid, :pgid, :fds, :sandbox_dir, keyword_init: true)
33
+ # `status` is filled in when the child is reaped, so a caller that got no
34
+ # payload out of it can still say how it died.
35
+ Handle = Struct.new(:pid, :pgid, :fds, :sandbox_dir, :status, keyword_init: true)
34
36
 
35
37
  @registry = [].freeze
36
38
 
@@ -158,7 +160,7 @@ class Evilution::ProcessSupervisor
158
160
  # release the resources the handle owns: close parent-side fds, remove the
159
161
  # sandbox dir, and drop the handle from the registry.
160
162
  def reap(handle)
161
- safe_wait(handle.pid)
163
+ handle.status = safe_wait(handle.pid)
162
164
  ensure
163
165
  release(handle)
164
166
  end
@@ -170,21 +172,28 @@ class Evilution::ProcessSupervisor
170
172
  # it releases the handle in the same step it reaps, so the process-global
171
173
  # registry never holds a stale, already-reaped pgid.
172
174
  def reap_nonblock(handle)
173
- return false unless nonblocking_wait(handle.pid)
175
+ outcome = nonblocking_wait(handle.pid)
176
+ return false unless outcome
174
177
 
178
+ handle.status = outcome if outcome.is_a?(::Process::Status)
175
179
  release(handle)
176
180
  true
177
181
  end
178
182
 
179
183
  private
180
184
 
181
- # WNOHANG wait: returns the pid once the child has exited, nil while it is
182
- # still running, and -- treating an already-reaped child as exited -- the pid
183
- # again on ECHILD so the caller still releases the handle.
185
+ # WNOHANG wait: returns the child's Process::Status once it has exited, nil
186
+ # while it is still running, and -- treating an already-reaped child as
187
+ # exited -- the :already_reaped sentinel on ECHILD so the caller still
188
+ # releases the handle. The sentinel is deliberately not a status: $? would
189
+ # then hold some earlier child's, and attributing that one's death to this
190
+ # child is worse than admitting the status is unknown.
184
191
  def nonblocking_wait(pid)
185
- ::Process.waitpid(pid, ::Process::WNOHANG)
192
+ return nil unless ::Process.waitpid(pid, ::Process::WNOHANG)
193
+
194
+ ::Process.last_status
186
195
  rescue Errno::ECHILD
187
- pid
196
+ :already_reaped
188
197
  end
189
198
 
190
199
  def release(handle)
@@ -229,8 +238,11 @@ class Evilution::ProcessSupervisor
229
238
  nil
230
239
  end
231
240
 
241
+ # Returns the reaped child's Process::Status, or nil when it was already
242
+ # reaped elsewhere and the status is no longer attributable.
232
243
  def safe_wait(pid)
233
244
  ::Process.wait(pid)
245
+ ::Process.last_status
234
246
  rescue Errno::ECHILD
235
247
  nil
236
248
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+ require_relative "result_location"
5
+
6
+ # The neutral mutations that share one reason, under a heading naming it.
7
+ #
8
+ # "Neutral" covers a spec that was already red and a test process that died on
9
+ # infrastructure; grouping by reason is what tells the reader which of the two
10
+ # they are looking at, and what to do about it (EV-5pob / GH #1606).
11
+ class Evilution::Reporter::CLI::ItemFormatters::NeutralGroup
12
+ UNKNOWN_REASON = "reason not recorded"
13
+
14
+ def initialize(location: Evilution::Reporter::CLI::ItemFormatters::ResultLocation.new)
15
+ @location = location
16
+ end
17
+
18
+ def format(group)
19
+ reason, results = group
20
+ rows = results.map { |result| " #{@location.format(result)}" }
21
+ [" #{reason ? reason.to_s : UNKNOWN_REASON}:", *rows].join("\n")
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+ require_relative "../pct"
5
+
6
+ # One line per subject, under its file's heading: what the method scored and
7
+ # what that score is made of.
8
+ #
9
+ # A subject nothing reached says so rather than showing a bare 0.00%, which
10
+ # would read like a method whose mutations all survived (EV-nlx1 / GH #1605).
11
+ class Evilution::Reporter::CLI::ItemFormatters::SubjectScore
12
+ UNREACHED_NOTE = "nothing reached this subject"
13
+
14
+ def initialize(pct: Evilution::Reporter::CLI::Pct.new)
15
+ @pct = pct
16
+ end
17
+
18
+ def format(score)
19
+ " #{method_name(score.name)} #{@pct.format(score.score)} #{counts(score)}#{note(score)}"
20
+ end
21
+
22
+ private
23
+
24
+ # The file heading already carries the class, so the row keeps the part that
25
+ # tells the subjects apart, separator included: "#label_for", ".build".
26
+ def method_name(name)
27
+ boundary = name.rindex(/[#.]/)
28
+ boundary ? name[boundary..] : name
29
+ end
30
+
31
+ # Killed over what got a verdict — except where nothing did, in which case the
32
+ # mutations that exist are the more honest denominator.
33
+ def counts(score)
34
+ return "(0/#{score.total})" unless score.reached?
35
+
36
+ "(#{score.killed}/#{score.verified})"
37
+ end
38
+
39
+ def note(score)
40
+ score.reached? ? "" : " #{UNREACHED_NOTE}"
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+ require_relative "subject_score"
5
+
6
+ # The subjects of one file, under that file's name.
7
+ class Evilution::Reporter::CLI::ItemFormatters::SubjectScoreGroup
8
+ def initialize(row: Evilution::Reporter::CLI::ItemFormatters::SubjectScore.new)
9
+ @row = row
10
+ end
11
+
12
+ def format(scores)
13
+ rows = scores.map { |score| @row.format(score) }
14
+ [" #{scores.first.file_path}", *rows].join("\n")
15
+ end
16
+ end