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,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
|
|
7
|
+
# Flatten a destructuring group in a block's parameter list:
|
|
8
|
+
# `pairs.each_with_index { |(key, value), index| ... }` becomes
|
|
9
|
+
# `pairs.each_with_index { |key, value, index| ... }`.
|
|
10
|
+
#
|
|
11
|
+
# The two forms bind differently once the group has a sibling: given `[1, 2]`
|
|
12
|
+
# and `3`, the grouped form binds key=1, value=2, index=3, while the flat form
|
|
13
|
+
# binds key=[1, 2], value=3, index=nil. A survivor means nothing asserts the
|
|
14
|
+
# shape of what the yielder hands over.
|
|
15
|
+
#
|
|
16
|
+
# A lone group is left alone. With one parameter and a single yielded array —
|
|
17
|
+
# what `each` over pairs, and `Hash#each`, actually yield — both forms bind the
|
|
18
|
+
# same values, so the mutation would be behaviour-preserving and survive every
|
|
19
|
+
# suite. The forms do diverge there for a yielder that passes several values,
|
|
20
|
+
# but which one a block is handed cannot be known from the source, and a
|
|
21
|
+
# guaranteed survivor on the common idiom is the worse trade.
|
|
22
|
+
#
|
|
23
|
+
# Methods and lambdas are left alone as well: both bind by arity, so flattening
|
|
24
|
+
# a group in their signatures raises ArgumentError at every call site instead of
|
|
25
|
+
# changing how arguments are distributed.
|
|
26
|
+
class Evilution::Mutator::Operator::BlockDestructuringExpansion < Evilution::Mutator::Base
|
|
27
|
+
def visit_block_node(node)
|
|
28
|
+
expand_groups(node)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def expand_groups(node)
|
|
35
|
+
parameters = node.parameters
|
|
36
|
+
return unless parameters.is_a?(Prism::BlockParametersNode)
|
|
37
|
+
|
|
38
|
+
inner = parameters.parameters
|
|
39
|
+
return unless inner.is_a?(Prism::ParametersNode)
|
|
40
|
+
return unless siblings?(inner)
|
|
41
|
+
|
|
42
|
+
groups(inner).each { |group| flatten_group(group) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Both lists can hold a destructuring group: `|(a, b), c|` puts it in
|
|
46
|
+
# requireds, `|*rest, (a, b)|` in posts.
|
|
47
|
+
def groups(parameters)
|
|
48
|
+
[*parameters.requireds, *parameters.posts].grep(Prism::MultiTargetNode)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def siblings?(parameters)
|
|
52
|
+
parameter_count(parameters) > 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Block-local variables (`|a; tmp|`) are not parameters and so are not
|
|
56
|
+
# siblings — they change nothing about how arguments bind.
|
|
57
|
+
def parameter_count(parameters)
|
|
58
|
+
[
|
|
59
|
+
*parameters.requireds,
|
|
60
|
+
*parameters.optionals,
|
|
61
|
+
parameters.rest,
|
|
62
|
+
*parameters.posts,
|
|
63
|
+
*parameters.keywords,
|
|
64
|
+
parameters.keyword_rest,
|
|
65
|
+
parameters.block
|
|
66
|
+
].compact.length
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Only the group's own parentheses go; anything nested inside keeps its own.
|
|
70
|
+
# A group written in a block's parameter list always carries them — the
|
|
71
|
+
# parenthesis-free MultiTargetNode belongs to multiple assignment, which is
|
|
72
|
+
# not reachable from here.
|
|
73
|
+
def flatten_group(group)
|
|
74
|
+
lparen = group.lparen_loc
|
|
75
|
+
rparen = group.rparen_loc
|
|
76
|
+
|
|
77
|
+
inner_start = lparen.start_offset + lparen.length
|
|
78
|
+
add_mutation(
|
|
79
|
+
offset: group.location.start_offset,
|
|
80
|
+
length: group.location.length,
|
|
81
|
+
replacement: byteslice_source(inner_start, rparen.start_offset - inner_start),
|
|
82
|
+
node: group
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
|
|
7
|
+
# Drop the single parameter of a block: `users.each { |u| touch(u) }` becomes
|
|
8
|
+
# `users.each { touch(u) }`.
|
|
9
|
+
#
|
|
10
|
+
# A block tolerates being handed more arguments than it declares, so the mutant
|
|
11
|
+
# runs until the body reads the dropped name, where it raises NameError. A
|
|
12
|
+
# survivor therefore means the block never ran on a path the suite asserts —
|
|
13
|
+
# the collection was empty, or the call was stubbed away.
|
|
14
|
+
#
|
|
15
|
+
# Not to be confused with BlockParamRemoval, which removes a method's `&block`
|
|
16
|
+
# parameter from its signature.
|
|
17
|
+
#
|
|
18
|
+
# Two shapes are deliberately left alone. A parameter the body never reads makes
|
|
19
|
+
# the mutation behaviour-preserving, so it would survive every suite and report
|
|
20
|
+
# a coverage gap that is not there. A lambda checks its arity, so dropping its
|
|
21
|
+
# parameter raises ArgumentError on every call, whatever the body does.
|
|
22
|
+
class Evilution::Mutator::Operator::BlockParameterDrop < Evilution::Mutator::Base
|
|
23
|
+
def visit_block_node(node)
|
|
24
|
+
drop_parameter(node)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def drop_parameter(node)
|
|
31
|
+
return unless droppable?(node)
|
|
32
|
+
|
|
33
|
+
location = node.parameters.location
|
|
34
|
+
leading = leading_space(location)
|
|
35
|
+
|
|
36
|
+
add_mutation(
|
|
37
|
+
offset: location.start_offset - leading,
|
|
38
|
+
length: location.length + leading,
|
|
39
|
+
replacement: "",
|
|
40
|
+
node: node
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def droppable?(node)
|
|
45
|
+
parameters = node.parameters
|
|
46
|
+
return false unless parameters.is_a?(Prism::BlockParametersNode)
|
|
47
|
+
return false unless parameters.locals.empty?
|
|
48
|
+
|
|
49
|
+
names = single_parameter_names(parameters.parameters)
|
|
50
|
+
names.any? { |name| body_reads?(node.body, name) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The space the block keyword leaves in front of the parameters goes with
|
|
54
|
+
# them, so `{ |u| body }` mutates to `{ body }` and `do |u|` to `do` rather
|
|
55
|
+
# than leaving a double space or a trailing one.
|
|
56
|
+
def leading_space(location)
|
|
57
|
+
preceding = @file_source.byteslice(location.start_offset - 1, 1)
|
|
58
|
+
preceding == " " ? 1 : 0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The names bound by a lone required parameter, which is either a plain name
|
|
62
|
+
# or a destructuring target (`|(key, value)|`). Anything else — a second
|
|
63
|
+
# parameter, an optional, a rest, a keyword, a block pass, or the implicit
|
|
64
|
+
# rest Prism reports for the trailing comma in `|key,|` — means this is not a
|
|
65
|
+
# single-parameter block, and numbered parameters carry no node to remove.
|
|
66
|
+
def single_parameter_names(parameters)
|
|
67
|
+
return [] unless parameters.is_a?(Prism::ParametersNode)
|
|
68
|
+
return [] unless parameters.requireds.length == 1
|
|
69
|
+
return [] unless other_parameters_absent?(parameters)
|
|
70
|
+
|
|
71
|
+
parameter_names(parameters.requireds.first)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Post-required parameters are not checked: block syntax only produces them
|
|
75
|
+
# after a rest parameter, which is rejected here anyway.
|
|
76
|
+
def other_parameters_absent?(parameters)
|
|
77
|
+
parameters.optionals.empty? && parameters.keywords.empty? &&
|
|
78
|
+
parameters.rest.nil? && parameters.keyword_rest.nil? && parameters.block.nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parameter_names(node)
|
|
82
|
+
case node
|
|
83
|
+
when Prism::RequiredParameterNode then [node.name.to_s]
|
|
84
|
+
when Prism::MultiTargetNode then node.lefts.flat_map { |child| parameter_names(child) }
|
|
85
|
+
else []
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# An underscore-prefixed name announces a parameter that is not meant to be
|
|
90
|
+
# read, so it is treated as unused even where the body happens to mention it.
|
|
91
|
+
def body_reads?(body, name)
|
|
92
|
+
return false if name.start_with?("_")
|
|
93
|
+
|
|
94
|
+
Evilution::AST::LocalReads.new.call(body, name)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -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
|
|
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
|
-
|
|
7
|
+
blank_branches(node, node.subsequent)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
18
|
-
return
|
|
19
|
-
|
|
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(
|
|
34
|
+
add_nil_mutation(else_clause.statements, node)
|
|
22
35
|
end
|
|
23
36
|
|
|
24
37
|
def add_nil_mutation(statements, node)
|
|
@@ -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,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
|
|
@@ -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?
|
|
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
|