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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
# `shown` is where the mutant is reported; offset and bytes are what it replaces.
|
|
5
|
+
Change = Data.define(:genre, :node, :shown, :offset, :bytes, :replacement, :description) do
|
|
6
|
+
def self.whole(node, replacement, description)
|
|
7
|
+
location = node.location
|
|
8
|
+
new(
|
|
9
|
+
genre: nil,
|
|
10
|
+
node:,
|
|
11
|
+
shown: location,
|
|
12
|
+
offset: location.start_offset,
|
|
13
|
+
bytes: location.length,
|
|
14
|
+
replacement:,
|
|
15
|
+
description:
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def at(location)
|
|
20
|
+
with(shown: location, offset: location.start_offset, bytes: location.length)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def covering(extent)
|
|
24
|
+
offset, bytes = extent
|
|
25
|
+
with(offset:, bytes:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def original(source)
|
|
29
|
+
source.byteslice(offset, bytes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Coverage marks a statement's first line only: continuation lines go by their statement.
|
|
33
|
+
def where(cursor)
|
|
34
|
+
span = (cursor.statement || node).location
|
|
35
|
+
{
|
|
36
|
+
line: shown.start_line,
|
|
37
|
+
col: shown.start_column + 1,
|
|
38
|
+
start_line: span.start_line,
|
|
39
|
+
end_line: span.end_line,
|
|
40
|
+
outer_lines: cursor.outer_lines(node)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def what(source)
|
|
45
|
+
{ genre:, offset:, bytes:, original: original(source), replacement:, description: }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
public_constant :Change
|
|
49
|
+
end
|
data/lib/mutato/child.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
# `exit!` throughout: a child must not run the parent's at_exit handlers.
|
|
5
|
+
class Child
|
|
6
|
+
CHUNK = 65_536
|
|
7
|
+
private_constant :CHUNK
|
|
8
|
+
|
|
9
|
+
def self.run(timeout: nil, log: nil, &)
|
|
10
|
+
new(timeout, log).run(&)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Whatever the block raises, the parent gets a result.
|
|
14
|
+
def self.attempt
|
|
15
|
+
yield
|
|
16
|
+
rescue SoftTimeout
|
|
17
|
+
{ outcome: :timeout }
|
|
18
|
+
rescue Exception => error
|
|
19
|
+
{
|
|
20
|
+
outcome: :crashed,
|
|
21
|
+
detail: "#{error.class}: #{error.message}",
|
|
22
|
+
backtrace: Array(error.backtrace).first(5)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A signal death (segfault, SIGTERM) counts against the mutant until confirmed.
|
|
27
|
+
def self.verdict(data, status)
|
|
28
|
+
return { outcome: :caught, failing: nil, signal: status.termsig } if status.signaled?
|
|
29
|
+
return { outcome: :crashed, detail: "exit status #{status.exitstatus}" } if data.empty?
|
|
30
|
+
|
|
31
|
+
Marshal.load(data)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(timeout, log)
|
|
35
|
+
@timeout = timeout
|
|
36
|
+
@log = log
|
|
37
|
+
@reader = nil
|
|
38
|
+
@pid = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Named, not anonymous: Ruby 3.3.0 rejects `&` forwarded from inside a block.
|
|
42
|
+
def run(&block)
|
|
43
|
+
Mutato.config.run_hooks(:before_fork)
|
|
44
|
+
# A project may set default_internal; Marshal's bytes must not be transcoded.
|
|
45
|
+
@reader, writer = IO.pipe(binmode: true)
|
|
46
|
+
@pid = fork { work(writer, &block) }
|
|
47
|
+
collect(writer)
|
|
48
|
+
ensure
|
|
49
|
+
sweep
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# A process group of its own: what the tests start and leave running dies with it.
|
|
55
|
+
def work(writer, &)
|
|
56
|
+
Process.setpgid(0, 0)
|
|
57
|
+
@reader.close
|
|
58
|
+
settle
|
|
59
|
+
deliver(writer, Child.attempt(&))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Not the parent's INT/TERM handlers: a mutant that signals itself must end only the child.
|
|
63
|
+
def settle
|
|
64
|
+
%w[INT TERM].each { |signal| trap(signal, "DEFAULT") }
|
|
65
|
+
Mutato.config.run_hooks(:after_fork)
|
|
66
|
+
redirect
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def redirect
|
|
70
|
+
STDIN.reopen(File::NULL)
|
|
71
|
+
log_output if @log
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The streams, not $stdout: a project's replacement object still writes to them.
|
|
75
|
+
def log_output
|
|
76
|
+
STDOUT.reopen(@log, "w")
|
|
77
|
+
STDERR.reopen(STDOUT)
|
|
78
|
+
STDOUT.sync = true
|
|
79
|
+
$stdout = STDOUT
|
|
80
|
+
$stderr = STDERR
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def deliver(writer, result)
|
|
84
|
+
writer.write(Marshal.dump(result))
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
# The parent is gone, and there is no one to tell; still no at_exit handlers.
|
|
87
|
+
ensure
|
|
88
|
+
writer.close
|
|
89
|
+
STDOUT.flush
|
|
90
|
+
exit! 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def collect(writer)
|
|
94
|
+
writer.close
|
|
95
|
+
data = +""
|
|
96
|
+
status = read_until_exit(data, @timeout && (Clock.now + @timeout))
|
|
97
|
+
status ? Child.verdict(data, status) : killed
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# To the child's exit, not EOF: a process the tests fork holds the pipe. nil past the deadline.
|
|
101
|
+
def read_until_exit(data, deadline)
|
|
102
|
+
loop do
|
|
103
|
+
status = exited
|
|
104
|
+
drain(data)
|
|
105
|
+
return status if status || Clock.left(deadline)&.zero?
|
|
106
|
+
|
|
107
|
+
@reader.wait_readable(0.05)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def exited
|
|
112
|
+
Process.wait2(@pid, Process::WNOHANG)&.last
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def drain(data)
|
|
116
|
+
while (chunk = @reader.read_nonblock(CHUNK, exception: false)).is_a?(String)
|
|
117
|
+
data << chunk
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def killed
|
|
122
|
+
Process.kill("KILL", @pid)
|
|
123
|
+
Process.wait(@pid)
|
|
124
|
+
File.write(@log, "\nmutato: killed after #{@timeout.round(1)} s\n", mode: "a") if @log
|
|
125
|
+
{ outcome: :timeout }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def sweep
|
|
129
|
+
@reader&.close
|
|
130
|
+
Process.kill("KILL", -@pid) if @pid
|
|
131
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/mutato/cli.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "abort"
|
|
4
|
+
require_relative "annotations"
|
|
5
|
+
require_relative "baseline"
|
|
6
|
+
require_relative "boot"
|
|
7
|
+
require_relative "campaign"
|
|
8
|
+
require_relative "child"
|
|
9
|
+
require_relative "clock"
|
|
10
|
+
require_relative "console"
|
|
11
|
+
require_relative "coverage_listener"
|
|
12
|
+
require_relative "covering"
|
|
13
|
+
require_relative "diff"
|
|
14
|
+
require_relative "equivalence"
|
|
15
|
+
require_relative "experience"
|
|
16
|
+
require_relative "flags"
|
|
17
|
+
require_relative "group_hooks"
|
|
18
|
+
require_relative "hits"
|
|
19
|
+
require_relative "measure"
|
|
20
|
+
require_relative "mode"
|
|
21
|
+
require_relative "options"
|
|
22
|
+
require_relative "output"
|
|
23
|
+
require_relative "report"
|
|
24
|
+
require_relative "rspec_adapter"
|
|
25
|
+
require_relative "run"
|
|
26
|
+
require_relative "selection"
|
|
27
|
+
require_relative "session"
|
|
28
|
+
require_relative "soft_timeout"
|
|
29
|
+
require_relative "suite"
|
|
30
|
+
require_relative "trial"
|
|
31
|
+
require_relative "watchdog"
|
|
32
|
+
|
|
33
|
+
module Mutato
|
|
34
|
+
class CLI
|
|
35
|
+
USAGE = <<~USAGE
|
|
36
|
+
usage: mutato list|run|control PATH... [options]
|
|
37
|
+
list the mutants that would be tried, without running anything
|
|
38
|
+
run baseline, then every mutant; survivors are the output
|
|
39
|
+
control reinstall every method unchanged; all must come out missed
|
|
40
|
+
options:
|
|
41
|
+
-h, --help -v, --version this text, the version
|
|
42
|
+
--spec ARGS rspec arguments: paths, --tag, ... (default spec)
|
|
43
|
+
--config FILE hooks and skips (default .mutato.rb)
|
|
44
|
+
--diff FILE|- only mutate lines a unified diff adds or changes
|
|
45
|
+
--format github|plain survivors as annotations or path:line:col lines
|
|
46
|
+
--limit N --sample N --genre a,b --only ID,ID
|
|
47
|
+
--out DIR (default mutato.out) --timeout-min SECONDS (default 10)
|
|
48
|
+
USAGE
|
|
49
|
+
MODES = { "run" => Mode::Run, "control" => Mode::Control }.freeze
|
|
50
|
+
COMMANDS = ["list", *MODES.keys].freeze
|
|
51
|
+
private_constant :USAGE, :MODES, :COMMANDS
|
|
52
|
+
|
|
53
|
+
# Returns the exit code; an Abort from anywhere, flag parsing included, ends here.
|
|
54
|
+
def self.call(argv, console: Console.new)
|
|
55
|
+
new(argv, console:).call
|
|
56
|
+
rescue Abort => error
|
|
57
|
+
console.say(error.message)
|
|
58
|
+
error.code
|
|
59
|
+
ensure
|
|
60
|
+
console.flush
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(argv, console:)
|
|
64
|
+
@console = console
|
|
65
|
+
# Loaded test code may rewrite ARGV.
|
|
66
|
+
argv = argv.dup
|
|
67
|
+
@options = parse(argv)
|
|
68
|
+
@command = argv.shift
|
|
69
|
+
@paths = argv.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def call
|
|
73
|
+
return show(info) if info
|
|
74
|
+
|
|
75
|
+
prepare
|
|
76
|
+
dispatch(Selection.new(@paths, @options))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def info
|
|
82
|
+
return USAGE if @options.help
|
|
83
|
+
|
|
84
|
+
"mutato #{VERSION}" if @options.version
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def prepare
|
|
88
|
+
check_paths
|
|
89
|
+
load_config
|
|
90
|
+
ARGV.clear
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# OptionParser reports a bad flag by raising; the user gets one line.
|
|
94
|
+
def parse(argv)
|
|
95
|
+
Options.parse(argv)
|
|
96
|
+
rescue OptionParser::ParseError => error
|
|
97
|
+
@console.die("#{error.message}; mutato --help lists the options")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def show(text)
|
|
101
|
+
@console.out(text)
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_paths
|
|
106
|
+
@console.die(USAGE) unless COMMANDS.include?(@command) && @paths.any?
|
|
107
|
+
missing = @paths.reject { |path| File.exist?(path) }
|
|
108
|
+
@console.die("no such path: #{missing.join(", ")}") if missing.any?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def load_config
|
|
112
|
+
named = @options.config
|
|
113
|
+
config = named || ".mutato.rb"
|
|
114
|
+
exists = File.exist?(config)
|
|
115
|
+
@console.die("no such config: #{config}") if named && !exists
|
|
116
|
+
load config if exists
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def dispatch(selection)
|
|
120
|
+
return list(selection) if @command == "list"
|
|
121
|
+
|
|
122
|
+
Session.new(MODES.fetch(@command), selection, @console).call
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def list(selection)
|
|
126
|
+
mutations = selection.chosen(selection.mutations)
|
|
127
|
+
print_listing(mutations)
|
|
128
|
+
@console.say(*selection.summary(mutations))
|
|
129
|
+
0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# On stdout, and out before the summary follows on stderr.
|
|
133
|
+
def print_listing(mutations)
|
|
134
|
+
mutations.each { |mutation| @console.out(mutation.listing) }
|
|
135
|
+
@console.flush
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/mutato/clock.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
# Seconds from a clock the system never sets back.
|
|
5
|
+
module Clock
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def now
|
|
9
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def since(started)
|
|
13
|
+
now - started
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Seconds until the deadline, never negative; nil without one.
|
|
17
|
+
def left(deadline)
|
|
18
|
+
[deadline - now, 0].max if deadline
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
class Config
|
|
5
|
+
Skip = Data.define(:pattern, :reason)
|
|
6
|
+
private_constant :Skip
|
|
7
|
+
|
|
8
|
+
attr_reader :hooks
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@hooks = Hash.new { |hash, name| hash[name] = [] }
|
|
12
|
+
@skips = []
|
|
13
|
+
@arid = Set.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run_hooks(name)
|
|
17
|
+
@hooks.fetch(name, []).each(&:call)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A String names one method exactly; a Regexp matches the qualified name.
|
|
21
|
+
def skip(pattern, reason)
|
|
22
|
+
pattern = /\A#{Regexp.escape(pattern)}\z/ if pattern.is_a?(String)
|
|
23
|
+
@skips << Skip.new(pattern:, reason:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def skip_reason(qualified)
|
|
27
|
+
@skips.find { |skip| skip.pattern.match?(qualified) }
|
|
28
|
+
&.reason
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def arid(*names)
|
|
32
|
+
@arid.merge(names.map(&:to_s))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def arid?(name)
|
|
36
|
+
@arid.include?(name.to_s)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "abort"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
# The real streams, duped at start: the project under test may replace $stdout.
|
|
7
|
+
class Console
|
|
8
|
+
def initialize(out: $stdout.dup, err: $stderr.dup)
|
|
9
|
+
@out = out
|
|
10
|
+
@err = err
|
|
11
|
+
@err.sync = true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Not Kernel#warn: RUBYOPT=-W0 silences that.
|
|
15
|
+
def say(*lines)
|
|
16
|
+
lines.each { |line| @err.puts(line) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def out(line)
|
|
20
|
+
@out.puts(line)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# CLI.call reports it and returns the code.
|
|
24
|
+
def die(message, code: 1)
|
|
25
|
+
raise Abort.new(message, code:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Stderr is unbuffered already.
|
|
29
|
+
def flush
|
|
30
|
+
@out.flush
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
class CoverageListener
|
|
5
|
+
Tally = Struct.new(:line_map, :durations, :failed, :seen, :began) do
|
|
6
|
+
def self.fresh
|
|
7
|
+
new(Hash.new { |hash, key| hash[key] = [] }, {}, [], Set.new, Clock.now)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def finish(example, seconds, hits)
|
|
11
|
+
id = example.id
|
|
12
|
+
durations[id] = seconds
|
|
13
|
+
failed << id if example.execution_result.status == :failed
|
|
14
|
+
hits.each { |key| line_map[key] << id }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Read right after the run: its time less the tests' is the hooks'. A Hash with a
|
|
18
|
+
# default block cannot be marshalled.
|
|
19
|
+
def to_h
|
|
20
|
+
hooks = [Clock.since(began) - durations.values.sum, 0].max
|
|
21
|
+
super.except(:began).merge(line_map: line_map.to_a.to_h, hooks:)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
public_constant :Tally
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
require_relative "coverage_listener/tally"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
# Each read clears the counters, so one read per example gives exactly its lines.
|
|
8
|
+
class CoverageListener
|
|
9
|
+
EVENTS = %i[example_group_started example_started example_finished].freeze
|
|
10
|
+
public_constant :EVENTS
|
|
11
|
+
|
|
12
|
+
attr_reader :tally
|
|
13
|
+
|
|
14
|
+
def initialize(prefixes)
|
|
15
|
+
@prefixes = prefixes
|
|
16
|
+
@tally = Tally.fresh
|
|
17
|
+
@hooks = GroupHooks.new
|
|
18
|
+
@started = Clock.now
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def example_group_started(_notification)
|
|
22
|
+
@hooks.credit_suite(@tally.line_map) { read_hits }
|
|
23
|
+
@hooks.expect_hooks
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def example_started(notification)
|
|
27
|
+
@started = Clock.now
|
|
28
|
+
@hooks.credit(notification.example.example_group, @tally.line_map) { read_hits }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def example_finished(notification)
|
|
32
|
+
@tally.finish(notification.example, Clock.since(@started), read_hits)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def read_hits
|
|
38
|
+
Coverage.result(stop: false, clear: true).flat_map do |path, data|
|
|
39
|
+
next [] unless path.start_with?(*@prefixes)
|
|
40
|
+
|
|
41
|
+
@tally.seen << path
|
|
42
|
+
Hits.keys(path, data)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
class Covering
|
|
5
|
+
def initialize(mutation, baseline)
|
|
6
|
+
@mutation = mutation
|
|
7
|
+
@baseline = baseline
|
|
8
|
+
@path = File.expand_path(mutation.file)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def ids
|
|
12
|
+
ids = stored? ? [] : @baseline.covering(@path, lines)
|
|
13
|
+
@baseline.prioritize(ids, @mutation.file)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def absence
|
|
17
|
+
return :unloaded unless @baseline.loaded?(@path)
|
|
18
|
+
# An endless def gets no line event when called.
|
|
19
|
+
return :uncovered if @mutation.subject.node.equal_loc
|
|
20
|
+
|
|
21
|
+
if @baseline.booted?(@path, stored? ? holding : lines)
|
|
22
|
+
:"load-time"
|
|
23
|
+
else
|
|
24
|
+
:uncovered
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Blocks run later than their method: outside one, skip its lines; inside, use its statement.
|
|
31
|
+
def lines
|
|
32
|
+
span = @mutation.span.to_a
|
|
33
|
+
@mutation.outer_lines ? span : span - @mutation.subject.block_lines
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# All its lines outside blocks: a multi-line literal's line event is on its first element.
|
|
37
|
+
def holding
|
|
38
|
+
@mutation.outer_lines.to_a - @mutation.subject.block_lines
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def stored?
|
|
42
|
+
@mutation.outer_lines && @baseline.covering(@path, holding).empty?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/mutato/diff.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
class Diff
|
|
5
|
+
def self.read(option)
|
|
6
|
+
new(option == "-" ? $stdin.read : File.read(option))
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# `+++ b/lib/a.rb`, maybe a tab and timestamp after (diff -u, hg); c/ i/ o/ w/ from git.
|
|
10
|
+
def self.path(row)
|
|
11
|
+
row.delete_prefix("+++ ").partition("\t").first.strip.sub(%r{\A[abciow]/}, "")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(text)
|
|
15
|
+
@lines = Hash.new { |hash, file| hash[file] = [] }
|
|
16
|
+
@file = nil
|
|
17
|
+
@line = 0
|
|
18
|
+
# Only the paths matter; a stray byte elsewhere must not stop the reading.
|
|
19
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub.each_line { |row| take(row) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Both paths from the current directory: `lib`, `./lib` and an absolute path agree.
|
|
23
|
+
def touches?(file, range)
|
|
24
|
+
@lines.fetch(File.expand_path(file), []).any? { |line| range.cover?(line) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def take(row)
|
|
30
|
+
if row.start_with?("+++ ") then @file = File.expand_path(Diff.path(row))
|
|
31
|
+
elsif (hunk = row.match(/\A@@ -\d+(?:,\d+)? \+(\d+)/)) then @line = Integer(hunk[1], 10)
|
|
32
|
+
elsif row.start_with?("+") then added
|
|
33
|
+
elsif row.start_with?(" ") then @line += 1
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def added
|
|
38
|
+
@lines[@file] << @line
|
|
39
|
+
@line += 1
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
class Emitter
|
|
7
|
+
attr_reader :source, :mutations
|
|
8
|
+
|
|
9
|
+
def initialize(file, source)
|
|
10
|
+
@file = file
|
|
11
|
+
@source = source
|
|
12
|
+
@seen = Set.new
|
|
13
|
+
@mutations = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add(change, subject, cursor)
|
|
17
|
+
replacement = change.replacement
|
|
18
|
+
return if change.original(@source) == replacement
|
|
19
|
+
return unless @seen.add?([change.offset, change.bytes, replacement])
|
|
20
|
+
|
|
21
|
+
@mutations << Mutation.new(
|
|
22
|
+
id: id_for(change, subject),
|
|
23
|
+
file: @file,
|
|
24
|
+
subject:,
|
|
25
|
+
**change.where(cursor),
|
|
26
|
+
**change.what(@source)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# The offset within the def: distinct for two `+` on a line, stable when other code moves.
|
|
33
|
+
def id_for(change, subject)
|
|
34
|
+
within = change.offset - subject.node.location.start_offset
|
|
35
|
+
parts = [
|
|
36
|
+
@file,
|
|
37
|
+
subject.qualified,
|
|
38
|
+
change.genre,
|
|
39
|
+
within,
|
|
40
|
+
change.original(@source),
|
|
41
|
+
change.replacement
|
|
42
|
+
]
|
|
43
|
+
Digest::SHA1.hexdigest(parts.join("\0"))[0, 12]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
module Equivalence
|
|
5
|
+
# BasicObject's is empty; Struct's, given nothing, sets members to the nil they hold.
|
|
6
|
+
EMPTY = [BasicObject.instance_method(:initialize), Struct.instance_method(:initialize)].freeze
|
|
7
|
+
private_constant :EMPTY
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# In a module, where `super` goes depends on the class that includes it.
|
|
12
|
+
def equivalent?(mutation)
|
|
13
|
+
subject = mutation.subject
|
|
14
|
+
empty_super?(mutation) && loaded?(subject) &&
|
|
15
|
+
EMPTY.include?(Installer.current(subject)&.super_method)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A class only the tests load is unknown here; its mutant runs, and the child loads it.
|
|
19
|
+
def loaded?(subject)
|
|
20
|
+
name = subject.owner_name
|
|
21
|
+
name.empty? || Object.const_defined?(name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A bare `super` with parameters passes them on.
|
|
25
|
+
def empty_super?(mutation)
|
|
26
|
+
text = mutation.original
|
|
27
|
+
text == "super()" || (text == "super" && mutation.subject.node.parameters.nil?)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
# culprits: per test sequence, the test that failed it unmutated, or nil when it passed.
|
|
5
|
+
Experience = Struct.new(:flaky, :culprits) do
|
|
6
|
+
def self.fresh
|
|
7
|
+
new(Set.new, {})
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
public_constant :Experience
|
|
11
|
+
end
|