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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +41 -0
- data/CHANGELOG.md +65 -0
- data/README.md +149 -13
- data/docs/architecture.md +20 -5
- data/docs/isolation.md +3 -4
- data/exe/evil +8 -1
- data/exe/evilution +8 -1
- data/lib/evilution/ast/local_reads.rb +43 -0
- data/lib/evilution/cli/exit_guard.rb +59 -0
- data/lib/evilution/cli/parser/options_builder.rb +1 -0
- data/lib/evilution/cli.rb +1 -0
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/diagnostic.rb +22 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
- data/lib/evilution/integration/loading/redefinition_recovery.rb +1 -1
- data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
- data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
- data/lib/evilution/integration/minitest.rb +2 -1
- data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +4 -1
- data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
- data/lib/evilution/integration/rspec.rb +48 -2
- data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
- data/lib/evilution/isolation/fork.rb +29 -8
- data/lib/evilution/mcp/complete_result_server.rb +41 -0
- data/lib/evilution/mcp/server.rb +2 -1
- data/lib/evilution/mutator/base.rb +14 -2
- data/lib/evilution/mutator/operator/block_destructuring_expansion.rb +85 -0
- data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -0
- data/lib/evilution/mutator/operator/boolean_expression_to_nil.rb +22 -0
- data/lib/evilution/mutator/operator/boolean_operand_promotion.rb +31 -0
- data/lib/evilution/mutator/operator/case_in.rb +64 -0
- data/lib/evilution/mutator/operator/case_when.rb +72 -1
- data/lib/evilution/mutator/operator/conditional_branch.rb +20 -7
- data/lib/evilution/mutator/operator/forwarding_super_to_explicit.rb +71 -0
- data/lib/evilution/mutator/operator/if_branch_swap.rb +48 -0
- data/lib/evilution/mutator/operator/loop_body_to_raise.rb +68 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
- data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
- data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
- data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -0
- data/lib/evilution/mutator/operator/pattern_predicate.rb +28 -0
- data/lib/evilution/mutator/operator/typed_default_return.rb +84 -0
- data/lib/evilution/mutator/primitives.rb +52 -0
- data/lib/evilution/mutator/registry.rb +14 -0
- data/lib/evilution/process_supervisor.rb +20 -8
- data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
- data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
- data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
- data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
- data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
- data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
- data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
- data/lib/evilution/reporter/cli/trailer.rb +11 -7
- data/lib/evilution/reporter/cli.rb +20 -4
- data/lib/evilution/reporter/json/subjects.rb +29 -0
- data/lib/evilution/reporter/json.rb +23 -1
- data/lib/evilution/result/mutation_result.rb +3 -2
- data/lib/evilution/result/neutral_reason.rb +35 -0
- data/lib/evilution/result/subject_score.rb +28 -0
- data/lib/evilution/result/subject_scorer.rb +37 -0
- data/lib/evilution/result/summary.rb +44 -2
- data/lib/evilution/runner/canary.rb +52 -5
- data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +9 -3
- data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
- data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
- data/lib/evilution/runner/mutation_executor.rb +32 -4
- data/lib/evilution/runner/report_publisher.rb +31 -9
- data/lib/evilution/runner/target_spec_audit.rb +43 -0
- data/lib/evilution/runner.rb +10 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +18 -0
- data/scripts/compare_targeting +4 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
# still running, and -- treating an already-reaped child as
|
|
183
|
-
#
|
|
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
|
-
|
|
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
|