mutato 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +218 -0
- data/exe/mutato +21 -0
- data/lib/mutato/abort.rb +12 -0
- data/lib/mutato/annotations.rb +65 -0
- data/lib/mutato/arid.rb +104 -0
- data/lib/mutato/baseline.rb +51 -0
- data/lib/mutato/boot.rb +99 -0
- data/lib/mutato/campaign.rb +52 -0
- data/lib/mutato/change.rb +49 -0
- data/lib/mutato/child.rb +135 -0
- data/lib/mutato/cli.rb +138 -0
- data/lib/mutato/clock.rb +21 -0
- data/lib/mutato/config.rb +39 -0
- data/lib/mutato/console.rb +33 -0
- data/lib/mutato/coverage_listener/tally.rb +26 -0
- data/lib/mutato/coverage_listener.rb +46 -0
- data/lib/mutato/covering.rb +45 -0
- data/lib/mutato/diff.rb +42 -0
- data/lib/mutato/emitter.rb +46 -0
- data/lib/mutato/equivalence.rb +30 -0
- data/lib/mutato/experience.rb +11 -0
- data/lib/mutato/flags.rb +58 -0
- data/lib/mutato/generation.rb +36 -0
- data/lib/mutato/generator.rb +10 -0
- data/lib/mutato/genres/conditions.rb +70 -0
- data/lib/mutato/genres/elements.rb +49 -0
- data/lib/mutato/genres/genre.rb +23 -0
- data/lib/mutato/genres/operators.rb +81 -0
- data/lib/mutato/genres/statements.rb +122 -0
- data/lib/mutato/genres/swaps.rb +58 -0
- data/lib/mutato/genres/values.rb +41 -0
- data/lib/mutato/genres.rb +53 -0
- data/lib/mutato/group_hooks.rb +45 -0
- data/lib/mutato/hits.rb +18 -0
- data/lib/mutato/installer/header.rb +40 -0
- data/lib/mutato/installer.rb +133 -0
- data/lib/mutato/measure.rb +37 -0
- data/lib/mutato/mode/control.rb +43 -0
- data/lib/mutato/mode/run.rb +33 -0
- data/lib/mutato/mode.rb +26 -0
- data/lib/mutato/mutation.rb +85 -0
- data/lib/mutato/nodes.rb +150 -0
- data/lib/mutato/options.rb +33 -0
- data/lib/mutato/output.rb +76 -0
- data/lib/mutato/privates.rb +46 -0
- data/lib/mutato/report.rb +36 -0
- data/lib/mutato/rspec_adapter/progress.rb +20 -0
- data/lib/mutato/rspec_adapter/result.rb +15 -0
- data/lib/mutato/rspec_adapter.rb +129 -0
- data/lib/mutato/run.rb +44 -0
- data/lib/mutato/selection.rb +100 -0
- data/lib/mutato/session.rb +53 -0
- data/lib/mutato/skips.rb +54 -0
- data/lib/mutato/soft_timeout.rb +8 -0
- data/lib/mutato/subject.rb +21 -0
- data/lib/mutato/subjects/scope.rb +44 -0
- data/lib/mutato/subjects.rb +67 -0
- data/lib/mutato/suite.rb +6 -0
- data/lib/mutato/trial.rb +93 -0
- data/lib/mutato/usage.rb +41 -0
- data/lib/mutato/version.rb +6 -0
- data/lib/mutato/visitor/cursor.rb +28 -0
- data/lib/mutato/visitor/walk.rb +17 -0
- data/lib/mutato/visitor.rb +88 -0
- data/lib/mutato/watchdog.rb +54 -0
- data/lib/mutato.rb +55 -0
- metadata +128 -0
data/lib/mutato/flags.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
module Flags
|
|
7
|
+
TABLE = [
|
|
8
|
+
[%w[-h --help], :help, TrueClass],
|
|
9
|
+
[%w[-v --version], :version, TrueClass],
|
|
10
|
+
["--spec ARGS", :spec, String],
|
|
11
|
+
["--config FILE", :config, String],
|
|
12
|
+
["--diff FILE", :diff, String],
|
|
13
|
+
["--format NAME", :format, String],
|
|
14
|
+
["--limit N", :limit, Integer],
|
|
15
|
+
["--sample N", :sample, Integer],
|
|
16
|
+
["--genre LIST", :genres, Array],
|
|
17
|
+
["--only IDS", :only, Array],
|
|
18
|
+
["--out DIR", :out, String],
|
|
19
|
+
["--timeout-min S", :timeout_min, Float]
|
|
20
|
+
].freeze
|
|
21
|
+
DEFAULTS = { spec: "spec", out: "mutato.out", timeout_min: 10.0 }.freeze
|
|
22
|
+
GENRES = %w[statement condition relational arithmetic swap-method value element].freeze
|
|
23
|
+
private_constant :TABLE, :DEFAULTS, :GENRES
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def parse(argv)
|
|
28
|
+
values = Options.members.to_h { |member| [member, DEFAULTS[member]] }
|
|
29
|
+
parser(values).parse!(argv)
|
|
30
|
+
check(values)
|
|
31
|
+
Options.new(**values)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Values OptionParser takes but mutato cannot use, reported the way it reports its own.
|
|
35
|
+
def check(values)
|
|
36
|
+
check_genres(values[:genres].to_a)
|
|
37
|
+
negative = %i[limit sample].find { |key| values[key]&.negative? }
|
|
38
|
+
raise OptionParser::InvalidArgument, "--#{negative} #{values[negative]}" if negative
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def check_genres(genres)
|
|
42
|
+
unknown = genres - GENRES
|
|
43
|
+
wrong = "--genre #{unknown.join(",")} (genres: #{GENRES.join(", ")})"
|
|
44
|
+
raise OptionParser::InvalidArgument, wrong if unknown.any?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parser(values)
|
|
48
|
+
parser = OptionParser.new
|
|
49
|
+
TABLE.each { |flag| define(parser, values, flag) }
|
|
50
|
+
parser
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def define(parser, values, flag)
|
|
54
|
+
switches, key, type = flag
|
|
55
|
+
parser.on(*switches, type) { |value| values[key] = value }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
class Generation
|
|
7
|
+
def initialize(file, source)
|
|
8
|
+
@file = file
|
|
9
|
+
@source = source
|
|
10
|
+
@tree = Prism.parse(source).value
|
|
11
|
+
@emitter = Emitter.new(file, source)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def generator
|
|
15
|
+
subjects = found
|
|
16
|
+
kept, dropped = mutate(subjects).partition { |mutation| parses?(mutation) }
|
|
17
|
+
Generator.new(file: @file, source: @source, subjects:, mutations: kept, dropped:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def found
|
|
23
|
+
usage = Usage.new(@tree)
|
|
24
|
+
Subjects.find(@file, @tree).map { |subject| usage.demoted(subject) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def mutate(subjects)
|
|
28
|
+
subjects.reject(&:skip).each { |subject| Visitor.new(subject, @emitter).run }
|
|
29
|
+
@emitter.mutations
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parses?(mutation)
|
|
33
|
+
Prism.parse(mutation.mutated_source(@source)).errors.empty?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "genre"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
module Genres
|
|
8
|
+
class Conditions < Genre
|
|
9
|
+
CONNECTORS = { Prism::AndNode => %w[|| or], Prism::OrNode => %w[&& and] }.freeze
|
|
10
|
+
LITERALS = [Prism::TrueNode, Prism::FalseNode].freeze
|
|
11
|
+
private_constant :CONNECTORS, :LITERALS
|
|
12
|
+
|
|
13
|
+
# `warn(...) unless ok`: a compound of arid parts is arid, children included.
|
|
14
|
+
def branch(node)
|
|
15
|
+
return :stop if Arid.arid_node?(node)
|
|
16
|
+
|
|
17
|
+
replace(node.predicate, literals(node))
|
|
18
|
+
spare_guard(node)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The literal that skips the body: `while false`, `until true`.
|
|
22
|
+
def loop(node)
|
|
23
|
+
replace(node.predicate, node.is_a?(Prism::WhileNode) ? %w[false] : %w[true])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def connector(node)
|
|
27
|
+
operator = node.operator_loc
|
|
28
|
+
spelled = operator.slice
|
|
29
|
+
replacement = CONNECTORS.fetch(node.class)[spelled.match?(/\A\w/) ? 1 : 0]
|
|
30
|
+
emit(
|
|
31
|
+
:condition,
|
|
32
|
+
Change.whole(
|
|
33
|
+
node,
|
|
34
|
+
replacement,
|
|
35
|
+
"replace `#{spelled}` with `#{replacement}`"
|
|
36
|
+
).at(operator)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def replace(predicate, literals)
|
|
43
|
+
return if predicate.nil? || Nodes.one_of?(predicate, LITERALS)
|
|
44
|
+
|
|
45
|
+
shown = Nodes.compact(predicate.slice)
|
|
46
|
+
literals.each do |literal|
|
|
47
|
+
emit(
|
|
48
|
+
:condition,
|
|
49
|
+
Change.whole(predicate, literal, "replace condition `#{shown}` with #{literal}")
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The disabling literal repeats a deletion, or for a memo guard only skips a cache.
|
|
55
|
+
def literals(node)
|
|
56
|
+
disabled = visitor.walk.deleted.include?(node) || Nodes.memo_guard?(node)
|
|
57
|
+
return %w[true false] unless Nodes.else_less?(node) && disabled
|
|
58
|
+
|
|
59
|
+
node.is_a?(Prism::IfNode) ? %w[true] : %w[false]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Deleting a guard's lone statement repeats a literal or the whole statement's deletion.
|
|
63
|
+
def spare_guard(node)
|
|
64
|
+
return unless Nodes.else_less?(node) && Nodes.single_statement(node)
|
|
65
|
+
|
|
66
|
+
visitor.walk.spared << node.statements
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "genre"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
module Genres
|
|
8
|
+
# Newlines stay with a dropped element, so line numbers hold.
|
|
9
|
+
class Elements < Genre
|
|
10
|
+
MAX = 10
|
|
11
|
+
private_constant :MAX
|
|
12
|
+
|
|
13
|
+
# A lone element has nothing to drop it from; only its trailing comma would go.
|
|
14
|
+
def mutate(node)
|
|
15
|
+
elements = node.elements
|
|
16
|
+
return unless (2..MAX).cover?(elements.size)
|
|
17
|
+
|
|
18
|
+
elements.each_index do |index|
|
|
19
|
+
drop(elements, index) unless Nodes.nil_valued?(elements[index])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def drop(elements, index)
|
|
26
|
+
from, to = range(elements, index)
|
|
27
|
+
element = elements[index]
|
|
28
|
+
newlines = "\n" * source.byteslice(from...to).count("\n")
|
|
29
|
+
change = Change.whole(element, newlines, "drop `#{Nodes.compact(element.slice)}`")
|
|
30
|
+
emit(:element, change.covering([from, to - from]))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The last element goes from the previous one's end, trailing comma included.
|
|
34
|
+
def range(elements, index)
|
|
35
|
+
location = elements[index].location
|
|
36
|
+
following = elements[index + 1]
|
|
37
|
+
return [location.start_offset, following.location.start_offset] if following
|
|
38
|
+
|
|
39
|
+
stop = location.end_offset
|
|
40
|
+
[elements[index - 1].location.end_offset, stop + trailing_comma(stop).bytesize]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Only a comma that ends the list: one before `&block` must stay.
|
|
44
|
+
def trailing_comma(offset)
|
|
45
|
+
source.byteslice(offset, 200).to_s.b[/\A\s*,(?=(?:\s|#[^\n]*)*[\]})])/].to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
module Genres
|
|
5
|
+
class Genre
|
|
6
|
+
def initialize(visitor)
|
|
7
|
+
@visitor = visitor
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
attr_reader :visitor
|
|
13
|
+
|
|
14
|
+
def emit(genre, change)
|
|
15
|
+
visitor.emit(genre, change)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def source
|
|
19
|
+
visitor.source
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "genre"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
module Genres
|
|
8
|
+
class Operators < Genre
|
|
9
|
+
RELATIONAL = {
|
|
10
|
+
"<" => %w[<= !=],
|
|
11
|
+
"<=" => %w[< ==],
|
|
12
|
+
">" => %w[>= !=],
|
|
13
|
+
">=" => %w[> ==],
|
|
14
|
+
"==" => %w[!=],
|
|
15
|
+
"!=" => %w[==]
|
|
16
|
+
}.freeze
|
|
17
|
+
# What a comparison gives when both sides are equal.
|
|
18
|
+
ALWAYS = { "<" => "false", "<=" => "true", ">" => "false", ">=" => "true" }.freeze
|
|
19
|
+
ARITHMETIC = { "+" => "-", "-" => "+", "*" => "/", "/" => "*" }.freeze
|
|
20
|
+
NON_COMMUTATIVE = %w[- / % **].freeze
|
|
21
|
+
private_constant :RELATIONAL, :ALWAYS, :ARITHMETIC, :NON_COMMUTATIVE
|
|
22
|
+
|
|
23
|
+
# A length is never negative, so `size > 0` is `size != 0`.
|
|
24
|
+
def self.neighbours(call, name)
|
|
25
|
+
same = name == ">" && Nodes.count_against_zero?(call)
|
|
26
|
+
RELATIONAL.fetch(name).reject { |operator| same && operator == "!=" }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def mutate(call)
|
|
30
|
+
name = call.name.to_s
|
|
31
|
+
relational(call, name) if RELATIONAL.key?(name)
|
|
32
|
+
arithmetic = ARITHMETIC[name]
|
|
33
|
+
operator(call, arithmetic, :arithmetic) if arithmetic
|
|
34
|
+
swap_operands(call) if NON_COMMUTATIVE.include?(name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def compound(node)
|
|
38
|
+
operator = node.binary_operator.to_s
|
|
39
|
+
replacement = ARITHMETIC[operator]
|
|
40
|
+
return unless replacement
|
|
41
|
+
|
|
42
|
+
change = Change.whole(
|
|
43
|
+
node,
|
|
44
|
+
"#{replacement}=",
|
|
45
|
+
"replace `#{operator}=` with `#{replacement}=`"
|
|
46
|
+
)
|
|
47
|
+
emit(:arithmetic, change.at(node.binary_operator_loc))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def relational(call, name)
|
|
53
|
+
Operators.neighbours(call, name).each { |operator| operator(call, operator, :relational) }
|
|
54
|
+
literal = ALWAYS[name]
|
|
55
|
+
always(call, literal) if literal
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def operator(call, replacement, genre)
|
|
59
|
+
change = Change.whole(call, replacement, "replace `#{call.name}` with `#{replacement}`")
|
|
60
|
+
emit(genre, change.at(call.message_loc))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def always(call, literal)
|
|
64
|
+
emit(
|
|
65
|
+
:relational,
|
|
66
|
+
Change.whole(call, literal, "replace `#{Nodes.compact(call.slice)}` with #{literal}")
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def swap_operands(call)
|
|
71
|
+
name = call.name
|
|
72
|
+
lhs = call.receiver.slice
|
|
73
|
+
rhs = Nodes.sole_argument(call).slice
|
|
74
|
+
emit(
|
|
75
|
+
:arithmetic,
|
|
76
|
+
Change.whole(call, "(#{rhs}) #{name} (#{lhs})", "swap operands of `#{name}`")
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "genre"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
module Genres
|
|
8
|
+
class Statements < Genre
|
|
9
|
+
LOOPS = [
|
|
10
|
+
Prism::WhileNode,
|
|
11
|
+
Prism::UntilNode,
|
|
12
|
+
Prism::ForNode,
|
|
13
|
+
Prism::BlockNode,
|
|
14
|
+
Prism::LambdaNode
|
|
15
|
+
].freeze
|
|
16
|
+
# `n += 1` and `n ||= x` read n as well.
|
|
17
|
+
READS = [
|
|
18
|
+
Prism::LocalVariableReadNode,
|
|
19
|
+
Prism::LocalVariableOperatorWriteNode,
|
|
20
|
+
Prism::LocalVariableOrWriteNode,
|
|
21
|
+
Prism::LocalVariableAndWriteNode
|
|
22
|
+
].freeze
|
|
23
|
+
EFFECTS = [
|
|
24
|
+
Prism::YieldNode,
|
|
25
|
+
Prism::SuperNode,
|
|
26
|
+
Prism::ForwardingSuperNode,
|
|
27
|
+
Prism::InstanceVariableWriteNode,
|
|
28
|
+
Prism::ClassVariableWriteNode,
|
|
29
|
+
Prism::GlobalVariableWriteNode
|
|
30
|
+
].freeze
|
|
31
|
+
private_constant :LOOPS, :READS, :EFFECTS
|
|
32
|
+
|
|
33
|
+
# Computing it has no effect: literals, reads, and operators between them.
|
|
34
|
+
def self.pure?(value)
|
|
35
|
+
Nodes.each_node(value).all? do |node|
|
|
36
|
+
node.is_a?(Prism::CallNode) ? Nodes.binary_operator?(node) : !Nodes.one_of?(node, EFFECTS)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.around?(node, offset)
|
|
41
|
+
span = node.location
|
|
42
|
+
Nodes.one_of?(node, LOOPS) && (span.start_offset...span.end_offset).cover?(offset)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Deleting these changes nothing a test can see.
|
|
46
|
+
def self.keep?(statement)
|
|
47
|
+
return true if Arid.arid_node?(statement) || Nodes.one_of?(statement, Nodes::LITERALS)
|
|
48
|
+
|
|
49
|
+
statement.is_a?(Prism::DefNode) || Nodes.memo_guard?(statement)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def mutate(node)
|
|
53
|
+
spared = visitor.walk.spared
|
|
54
|
+
return if spared.include?(node)
|
|
55
|
+
|
|
56
|
+
statements = node.body
|
|
57
|
+
statements.each do |statement|
|
|
58
|
+
consider(statement, statements.last) unless spared.include?(statement)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def consider(statement, valued)
|
|
65
|
+
return visitor.walk.dead << statement if dead_store?(statement, valued)
|
|
66
|
+
|
|
67
|
+
delete(statement) unless Statements.keep?(statement)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def delete(statement)
|
|
71
|
+
visitor.walk.deleted << statement
|
|
72
|
+
change = Change.whole(
|
|
73
|
+
statement,
|
|
74
|
+
"nil",
|
|
75
|
+
"delete statement `#{Nodes.compact(statement.slice)}`"
|
|
76
|
+
)
|
|
77
|
+
visitor.with_statement(statement) do
|
|
78
|
+
emit(:statement, change.covering(Nodes.extent(statement, source)))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A local store ending its list is the list's value; of ivars, only initialize's `@x = nil`.
|
|
83
|
+
def dead_store?(statement, valued)
|
|
84
|
+
case statement
|
|
85
|
+
when Prism::LocalVariableWriteNode then !statement.equal?(valued) && dead_local?(statement)
|
|
86
|
+
when Prism::InstanceVariableWriteNode then initial_nil?(statement)
|
|
87
|
+
when Prism::CallNode then Nodes.nil_store?(statement)
|
|
88
|
+
else false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def dead_local?(write)
|
|
93
|
+
unread_local?(write) && Statements.pure?(write.value)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initial_nil?(write)
|
|
97
|
+
visitor.subject.name == :initialize && write.value.is_a?(Prism::NilNode)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# In a loop or block, a read earlier in the body sees the write on the next pass.
|
|
101
|
+
def unread_local?(write)
|
|
102
|
+
from = outermost_loop_start(write) || write.location.end_offset
|
|
103
|
+
reads_of(write.name).none? { |read| (from..).cover?(read.location.start_offset) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def outermost_loop_start(write)
|
|
107
|
+
offset = write.location.start_offset
|
|
108
|
+
nodes = Nodes.each_node(visitor.subject.node)
|
|
109
|
+
starts = nodes.filter_map do |node|
|
|
110
|
+
node.location.start_offset if Statements.around?(node, offset)
|
|
111
|
+
end
|
|
112
|
+
starts.min
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reads_of(name)
|
|
116
|
+
Nodes.each_node(visitor.subject.node).select do |node|
|
|
117
|
+
Nodes.one_of?(node, READS) && node.name == name
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "genre"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
module Genres
|
|
8
|
+
class Swaps < Genre
|
|
9
|
+
SWAPS = {
|
|
10
|
+
max: %i[min],
|
|
11
|
+
min: %i[max],
|
|
12
|
+
first: %i[last],
|
|
13
|
+
last: %i[first],
|
|
14
|
+
round: %i[floor ceil],
|
|
15
|
+
floor: %i[round ceil],
|
|
16
|
+
ceil: %i[round floor],
|
|
17
|
+
truncate: %i[round],
|
|
18
|
+
any?: %i[all? none?],
|
|
19
|
+
all?: %i[any?],
|
|
20
|
+
none?: %i[any?],
|
|
21
|
+
empty?: %i[any?]
|
|
22
|
+
}.freeze
|
|
23
|
+
FINDERS = %i[first last].freeze
|
|
24
|
+
private_constant :SWAPS, :FINDERS
|
|
25
|
+
|
|
26
|
+
# `first(id: x)` is a finder, and a receiverless `max` the method's own: not Array calls.
|
|
27
|
+
def self.swappable?(call)
|
|
28
|
+
return false if call.receiver.nil?
|
|
29
|
+
|
|
30
|
+
!(FINDERS.include?(call.name) && (call.arguments || call.block))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mutate(call)
|
|
34
|
+
name = call.name
|
|
35
|
+
negation(call) if name == :! && call.receiver
|
|
36
|
+
replacements = SWAPS[name]
|
|
37
|
+
methods(call, replacements) if replacements && Swaps.swappable?(call)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def negation(call)
|
|
43
|
+
emit(
|
|
44
|
+
:condition,
|
|
45
|
+
Change.whole(call, call.receiver.slice, "drop `!` from `#{Nodes.compact(call.slice)}`")
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def methods(call, replacements)
|
|
50
|
+
name = call.name
|
|
51
|
+
replacements.each do |replacement|
|
|
52
|
+
change = Change.whole(call, replacement.to_s, "replace `.#{name}` with `.#{replacement}`")
|
|
53
|
+
emit(:"swap-method", change.at(call.message_loc))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "genre"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
module Genres
|
|
8
|
+
class Values < Genre
|
|
9
|
+
GUESSES = {
|
|
10
|
+
Prism::ArrayNode => "[]",
|
|
11
|
+
Prism::HashNode => "{}",
|
|
12
|
+
Prism::StringNode => '""',
|
|
13
|
+
Prism::InterpolatedStringNode => '""',
|
|
14
|
+
Prism::IntegerNode => "0",
|
|
15
|
+
Prism::TrueNode => "true",
|
|
16
|
+
Prism::FalseNode => "true"
|
|
17
|
+
}.freeze
|
|
18
|
+
private_constant :GUESSES
|
|
19
|
+
|
|
20
|
+
def mutate(body)
|
|
21
|
+
# A def with a rescue: the statements are what the method returns.
|
|
22
|
+
body = body.statements if body.is_a?(Prism::BeginNode)
|
|
23
|
+
return unless body.is_a?(Prism::StatementsNode)
|
|
24
|
+
|
|
25
|
+
guesses(body.body.last).each do |value|
|
|
26
|
+
description = "replace body of #{visitor.subject} with #{value}"
|
|
27
|
+
emit(:value, Change.whole(body, value, description).covering(Nodes.extent(body, source)))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def guesses(last)
|
|
34
|
+
return %w[true false] if visitor.subject.name.to_s.end_with?("?")
|
|
35
|
+
|
|
36
|
+
guessed = GUESSES.filter_map { |klass, value| value if last.is_a?(klass) }
|
|
37
|
+
["nil", *guessed].uniq
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "genres/conditions"
|
|
5
|
+
require_relative "genres/elements"
|
|
6
|
+
require_relative "genres/operators"
|
|
7
|
+
require_relative "genres/statements"
|
|
8
|
+
require_relative "genres/swaps"
|
|
9
|
+
require_relative "genres/values"
|
|
10
|
+
|
|
11
|
+
module Mutato
|
|
12
|
+
module Genres
|
|
13
|
+
COLLECTIONS = [Prism::ArrayNode, Prism::HashNode, Prism::KeywordHashNode].freeze
|
|
14
|
+
COMPOUNDS = [
|
|
15
|
+
Prism::LocalVariableOperatorWriteNode,
|
|
16
|
+
Prism::InstanceVariableOperatorWriteNode,
|
|
17
|
+
Prism::ClassVariableOperatorWriteNode,
|
|
18
|
+
Prism::GlobalVariableOperatorWriteNode,
|
|
19
|
+
Prism::IndexOperatorWriteNode,
|
|
20
|
+
Prism::CallOperatorWriteNode,
|
|
21
|
+
Prism::ConstantOperatorWriteNode,
|
|
22
|
+
Prism::ConstantPathOperatorWriteNode
|
|
23
|
+
].freeze
|
|
24
|
+
private_constant :COLLECTIONS, :COMPOUNDS
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# :stop leaves the node's children alone.
|
|
29
|
+
def mutate(visitor, node)
|
|
30
|
+
case node
|
|
31
|
+
when Prism::IfNode, Prism::UnlessNode then Conditions.new(visitor).branch(node)
|
|
32
|
+
when Prism::WhileNode, Prism::UntilNode then Conditions.new(visitor).loop(node)
|
|
33
|
+
when Prism::AndNode, Prism::OrNode then Conditions.new(visitor).connector(node)
|
|
34
|
+
when Prism::CallNode then call(visitor, node)
|
|
35
|
+
else other(visitor, node)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def other(visitor, node)
|
|
40
|
+
return Elements.new(visitor).mutate(node) if Nodes.one_of?(node, COLLECTIONS)
|
|
41
|
+
|
|
42
|
+
Operators.new(visitor).compound(node) if Nodes.one_of?(node, COMPOUNDS)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Nothing inside a logger call, or a loop that only logs, is mutated either.
|
|
46
|
+
def call(visitor, call)
|
|
47
|
+
return :stop if Arid.arid_node?(call)
|
|
48
|
+
|
|
49
|
+
Operators.new(visitor).mutate(call) if Nodes.binary_operator?(call)
|
|
50
|
+
Swaps.new(visitor).mutate(call)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
# Lines a before(:all) hook hits count for every example of its group.
|
|
5
|
+
class GroupHooks
|
|
6
|
+
def self.assign(ids, line_map)
|
|
7
|
+
yield.each { |key| line_map[key].concat(ids) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.all_ids
|
|
11
|
+
RSpec.world.example_groups.flat_map(&:descendant_filtered_examples).map(&:id)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@pending = false
|
|
16
|
+
@suite = true
|
|
17
|
+
@ids = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# At the first group: what before(:suite) ran, which every example runs after.
|
|
21
|
+
def credit_suite(line_map, &)
|
|
22
|
+
return unless @suite
|
|
23
|
+
|
|
24
|
+
@suite = false
|
|
25
|
+
GroupHooks.assign(GroupHooks.all_ids, line_map, &)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def expect_hooks
|
|
29
|
+
@pending = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def credit(group, line_map, &)
|
|
33
|
+
return unless @pending
|
|
34
|
+
|
|
35
|
+
@pending = false
|
|
36
|
+
GroupHooks.assign(served(group), line_map, &)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def served(group)
|
|
42
|
+
@ids[group] ||= group.parent_groups.flat_map(&:descendant_filtered_examples).map(&:id).uniq
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/mutato/hits.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
# Method start lines too, when coverage counts methods: an endless def gets no line hit.
|
|
5
|
+
module Hits
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def keys(path, data)
|
|
9
|
+
lines = data.fetch(:lines).each_with_index.filter_map do |count, index|
|
|
10
|
+
"#{path}:#{index + 1}" if count&.positive?
|
|
11
|
+
end
|
|
12
|
+
methods = data.fetch(:methods, {}).filter_map do |(_, _, line), count|
|
|
13
|
+
"#{path}:#{line}" if count.positive?
|
|
14
|
+
end
|
|
15
|
+
lines + methods
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|