mutaruby 0.1.0 → 0.1.1
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/CHANGELOG.md +23 -0
- data/README.md +13 -4
- data/lib/mutaruby/cli.rb +75 -67
- data/lib/mutaruby/command.rb +40 -31
- data/lib/mutaruby/error.rb +6 -0
- data/lib/mutaruby/generator.rb +59 -74
- data/lib/mutaruby/ignore_file.rb +26 -30
- data/lib/mutaruby/mutation.rb +16 -13
- data/lib/mutaruby/parsing.rb +15 -19
- data/lib/mutaruby/reporters.rb +32 -27
- data/lib/mutaruby/runner.rb +67 -72
- data/lib/mutaruby/sandbox.rb +18 -16
- data/lib/mutaruby/selection.rb +21 -30
- data/lib/mutaruby/version.rb +1 -1
- data/lib/mutaruby.rb +0 -15
- metadata +2 -1
data/lib/mutaruby/runner.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'set'
|
|
4
|
-
require_relative 'generator'
|
|
5
3
|
require_relative 'ignore_file'
|
|
6
4
|
require_relative 'sandbox'
|
|
7
5
|
require_relative 'command'
|
|
@@ -31,82 +29,79 @@ module Mutaruby
|
|
|
31
29
|
|
|
32
30
|
Options = Struct.new(:root, :command, :patterns, :since, :jobs, :timeout, :timeout_factor, :methods,
|
|
33
31
|
:exclude_methods, :lines, :exclude_lines, :shard, :ignore_file, :operators, :copy_excludes,
|
|
34
|
-
:progress, :
|
|
32
|
+
:progress, keyword_init: true) do
|
|
33
|
+
def self.build(**kw)
|
|
34
|
+
new(root: '.', patterns: [], jobs: 1, timeout_factor: 3.0, methods: [], exclude_methods: [], lines: [],
|
|
35
|
+
exclude_lines: [], copy_excludes: [], progress: false, **kw)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
MARK = { killed: '.', timeout: 'T', survived: 'S', equivalent: 'e' }.freeze
|
|
35
40
|
|
|
36
41
|
def initialize(opts, out: $stderr)
|
|
37
42
|
@o = opts
|
|
38
43
|
@out = out
|
|
39
|
-
@root = File.expand_path(opts.root
|
|
40
|
-
@warnings = []
|
|
44
|
+
@root = File.expand_path(opts.root)
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
# Mutants of the selected files after every filter, plus what is needed to report them.
|
|
44
48
|
def plan
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
end
|
|
55
|
-
all.each_with_index { |m, i| m.id = i }
|
|
56
|
-
ignore = load_ignore
|
|
57
|
-
if ignore
|
|
58
|
-
errs = ignore.validate(sources)
|
|
59
|
-
raise Error, "ignore file #{ignore.path}:\n #{errs.join("\n ")}" if errs.any?
|
|
60
|
-
end
|
|
61
|
-
{ files: files, sources: sources, mutations: sel.filter(all), ignore: ignore, complete: sel.complete? && @o.operators.nil? }
|
|
49
|
+
sel = selection
|
|
50
|
+
files = sel.files
|
|
51
|
+
sources = files.to_h { |f| [f, File.read(File.join(@root, f), encoding: 'UTF-8')] }
|
|
52
|
+
warnings = []
|
|
53
|
+
all = files.flat_map do |f|
|
|
54
|
+
g = Generator.new(sources[f], path: File.join(@root, f), rel: f, operators: @o.operators)
|
|
55
|
+
ms = g.mutations
|
|
56
|
+
warnings.concat(g.errors)
|
|
57
|
+
ms
|
|
62
58
|
end
|
|
59
|
+
all.each_with_index { |m, i| m.id = i }
|
|
60
|
+
ignore = load_ignore
|
|
61
|
+
errs = ignore ? ignore.validate(sources) : []
|
|
62
|
+
raise Error, "ignore file #{ignore.path}:\n #{errs.join("\n ")}" if errs.any?
|
|
63
|
+
|
|
64
|
+
{ files: files, sources: sources, mutations: sel.filter(all), ignore: ignore, warnings: warnings,
|
|
65
|
+
complete: sel.complete? && @o.operators.nil? }
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def run
|
|
66
|
-
t0 = monotonic
|
|
67
|
-
p = plan
|
|
68
69
|
sandboxes = []
|
|
69
|
-
sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes || [])
|
|
70
70
|
cmd = Command.new(@o.command, root: @root)
|
|
71
|
-
|
|
71
|
+
t0 = monotonic
|
|
72
|
+
p = plan
|
|
73
|
+
first = Sandbox.new(@root, excludes: @o.copy_excludes)
|
|
74
|
+
sandboxes << first
|
|
75
|
+
base = cmd.run(first.dir, capture: true)
|
|
72
76
|
unless base.passed?
|
|
73
|
-
raise BaselineFailed, "the test command fails on the UNMUTATED code (
|
|
74
|
-
"
|
|
75
|
-
"
|
|
77
|
+
raise BaselineFailed, "the test command fails on the UNMUTATED code (exit #{base.exitstatus}); every mutant " \
|
|
78
|
+
"would count as killed, so nothing would be measured.\ncommand: #{@o.command}\n" \
|
|
79
|
+
"--- output (tail) ---\n#{base.output}"
|
|
76
80
|
end
|
|
77
|
-
timeout = @o.timeout || ((base.seconds *
|
|
81
|
+
timeout = @o.timeout || ((base.seconds * @o.timeout_factor) + 5).ceil
|
|
78
82
|
outcomes = execute(p, cmd, sandboxes, timeout)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
matched = outcomes.select { |o| o.status == :equivalent }.map(&:ignore).to_set
|
|
82
|
-
stale = p[:complete] ? p[:ignore].stale(matched, p[:files]) : []
|
|
83
|
-
end
|
|
83
|
+
matched = outcomes.select { |o| o.status == :equivalent }.map(&:ignore)
|
|
84
|
+
stale = p[:ignore] && p[:complete] ? p[:ignore].stale(matched, p[:files]) : []
|
|
84
85
|
Report.new(root: @root, command: @o.command, files: p[:files], outcomes: outcomes, sources: p[:sources],
|
|
85
|
-
stale: stale, warnings:
|
|
86
|
+
stale: stale, warnings: p[:warnings], baseline_seconds: base.seconds, timeout: timeout,
|
|
86
87
|
jobs: jobs, seconds: monotonic - t0, complete: p[:complete])
|
|
87
|
-
rescue Interrupt
|
|
88
|
-
cmd
|
|
88
|
+
rescue Interrupt
|
|
89
|
+
cmd.kill_all
|
|
89
90
|
raise
|
|
90
91
|
ensure
|
|
91
|
-
sandboxes
|
|
92
|
+
sandboxes.each(&:cleanup)
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
private
|
|
95
96
|
|
|
96
97
|
def jobs
|
|
97
|
-
[@o.jobs
|
|
98
|
+
[@o.jobs, 1].max
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
def selection
|
|
101
|
-
Selection.new(root: @root, patterns: @o.patterns
|
|
102
|
-
exclude_methods: @o.exclude_methods
|
|
103
|
-
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def operator_set
|
|
107
|
-
return nil unless @o.operators
|
|
108
|
-
|
|
109
|
-
@o.operators.to_set
|
|
102
|
+
Selection.new(root: @root, patterns: @o.patterns, since: @o.since, methods: @o.methods,
|
|
103
|
+
exclude_methods: @o.exclude_methods, lines: @o.lines, exclude_lines: @o.exclude_lines,
|
|
104
|
+
shard: @o.shard)
|
|
110
105
|
end
|
|
111
106
|
|
|
112
107
|
def load_ignore
|
|
@@ -124,30 +119,13 @@ module Mutaruby
|
|
|
124
119
|
queue = Queue.new
|
|
125
120
|
plan[:mutations].each { |m| queue << m }
|
|
126
121
|
n = [jobs, plan[:mutations].size].min
|
|
127
|
-
|
|
122
|
+
sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes) while sandboxes.size < n
|
|
128
123
|
results = []
|
|
129
124
|
lock = Mutex.new
|
|
130
|
-
threads =
|
|
125
|
+
threads = sandboxes.map do |box|
|
|
131
126
|
Thread.new do
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
m = begin
|
|
135
|
-
queue.pop(true)
|
|
136
|
-
rescue ThreadError
|
|
137
|
-
break
|
|
138
|
-
end
|
|
139
|
-
original = plan[:sources][m.file]
|
|
140
|
-
r = box.with_file(m.file, m.text, original) do
|
|
141
|
-
cmd.run(box.dir, timeout: timeout,
|
|
142
|
-
env: { 'MUTARUBY_MUTANT_ID' => m.id.to_s, 'MUTARUBY_FILE' => m.file })
|
|
143
|
-
end
|
|
144
|
-
entry = plan[:ignore]&.match(m)
|
|
145
|
-
status = if r.timeout? then :timeout
|
|
146
|
-
elsif !r.passed? then :killed
|
|
147
|
-
elsif entry then :equivalent
|
|
148
|
-
else :survived
|
|
149
|
-
end
|
|
150
|
-
o = Outcome.new(mutation: m, status: status, seconds: r.seconds, ignore: entry)
|
|
127
|
+
while (m = next_mutation(queue))
|
|
128
|
+
o = evaluate(m, box, plan, cmd, timeout)
|
|
151
129
|
lock.synchronize do
|
|
152
130
|
results << o
|
|
153
131
|
progress(o)
|
|
@@ -157,7 +135,7 @@ module Mutaruby
|
|
|
157
135
|
end
|
|
158
136
|
begin
|
|
159
137
|
threads.each(&:join)
|
|
160
|
-
rescue
|
|
138
|
+
rescue Interrupt
|
|
161
139
|
threads.each(&:kill)
|
|
162
140
|
raise
|
|
163
141
|
end
|
|
@@ -165,7 +143,24 @@ module Mutaruby
|
|
|
165
143
|
results.sort_by { |o| o.mutation.id }
|
|
166
144
|
end
|
|
167
145
|
|
|
168
|
-
|
|
146
|
+
def next_mutation(queue)
|
|
147
|
+
queue.pop(true)
|
|
148
|
+
rescue ThreadError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def evaluate(m, box, plan, cmd, timeout)
|
|
153
|
+
r = box.with_file(m.file, m.text, plan[:sources][m.file]) do
|
|
154
|
+
cmd.run(box.dir, timeout: timeout, env: { 'MUTARUBY_MUTANT_ID' => m.id.to_s, 'MUTARUBY_FILE' => m.file })
|
|
155
|
+
end
|
|
156
|
+
entry = plan[:ignore]&.match(m)
|
|
157
|
+
status = if r.timeout? then :timeout
|
|
158
|
+
elsif !r.passed? then :killed
|
|
159
|
+
elsif entry then :equivalent
|
|
160
|
+
else :survived
|
|
161
|
+
end
|
|
162
|
+
Outcome.new(mutation: m, status: status, seconds: r.seconds, ignore: entry)
|
|
163
|
+
end
|
|
169
164
|
|
|
170
165
|
def progress(outcome)
|
|
171
166
|
return unless @o.progress
|
data/lib/mutaruby/sandbox.rb
CHANGED
|
@@ -12,14 +12,17 @@ module Mutaruby
|
|
|
12
12
|
# is written back before the next mutant.
|
|
13
13
|
class Sandbox
|
|
14
14
|
DEFAULT_EXCLUDES = %w[.git coverage].freeze
|
|
15
|
+
PREFIX = 'mutaruby-'
|
|
15
16
|
|
|
16
17
|
attr_reader :dir
|
|
17
18
|
|
|
18
|
-
def initialize(root, excludes: []
|
|
19
|
-
|
|
19
|
+
def initialize(root, excludes: [])
|
|
20
|
+
# The real path: Find does not descend into a symlink, so a root reached through one (/tmp on macOS) would
|
|
21
|
+
# otherwise be copied as an empty directory.
|
|
22
|
+
@root = File.realpath(root)
|
|
20
23
|
@excludes = DEFAULT_EXCLUDES + excludes
|
|
21
|
-
@
|
|
22
|
-
@
|
|
24
|
+
@base = Dir.mktmpdir(PREFIX)
|
|
25
|
+
@tmp_parent = File.realpath(File.dirname(@base))
|
|
23
26
|
@dir = File.join(@base, File.basename(@root))
|
|
24
27
|
copy_tree
|
|
25
28
|
end
|
|
@@ -31,10 +34,12 @@ module Mutaruby
|
|
|
31
34
|
# Writes `text` to `rel` inside the sandbox, yields, and restores the original bytes afterwards (also on error).
|
|
32
35
|
def with_file(rel, text, original)
|
|
33
36
|
target = path(rel)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
begin
|
|
38
|
+
File.binwrite(target, text)
|
|
39
|
+
yield
|
|
40
|
+
ensure
|
|
41
|
+
File.binwrite(target, original)
|
|
42
|
+
end
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def cleanup
|
|
@@ -51,10 +56,10 @@ module Mutaruby
|
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
# A sandbox directory (this one or another worker's). When the temporary directory lies inside the project (for
|
|
54
|
-
# example TMPDIR=./tmp), copying it would copy the sandbox into itself.
|
|
59
|
+
# example TMPDIR=./tmp), copying it would copy the sandbox into itself. Paths found under the real root are real.
|
|
55
60
|
def sandbox?(path)
|
|
56
|
-
File.
|
|
57
|
-
File.
|
|
61
|
+
File.directory?(path) && File.basename(path).start_with?(PREFIX) &&
|
|
62
|
+
File.dirname(path) == @tmp_parent
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
def copy_tree
|
|
@@ -63,17 +68,14 @@ module Mutaruby
|
|
|
63
68
|
next if src == @root
|
|
64
69
|
|
|
65
70
|
rel = src.delete_prefix("#{@root}/")
|
|
66
|
-
if excluded?(rel) || sandbox?(src)
|
|
67
|
-
Find.prune
|
|
68
|
-
next
|
|
69
|
-
end
|
|
71
|
+
Find.prune if excluded?(rel) || sandbox?(src)
|
|
70
72
|
dest = File.join(@dir, rel)
|
|
71
73
|
st = File.lstat(src)
|
|
72
74
|
if st.symlink?
|
|
73
75
|
File.symlink(File.readlink(src), dest)
|
|
74
76
|
elsif st.directory?
|
|
75
77
|
FileUtils.mkdir_p(dest)
|
|
76
|
-
elsif st.file?
|
|
78
|
+
elsif st.file? # not sockets, FIFOs or devices: copying a FIFO would block
|
|
77
79
|
FileUtils.cp(src, dest, preserve: true)
|
|
78
80
|
end
|
|
79
81
|
end
|
data/lib/mutaruby/selection.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
|
+
require_relative 'error'
|
|
4
5
|
|
|
5
6
|
module Mutaruby
|
|
6
7
|
# Which files to mutate and which of their mutants to run.
|
|
@@ -21,16 +22,17 @@ module Mutaruby
|
|
|
21
22
|
to = (m[3] || m[2]).to_i
|
|
22
23
|
raise Error, "bad line spec #{spec.inspect} (FROM > TO)" if from > to
|
|
23
24
|
|
|
24
|
-
LineSpec.new(m[1]&.
|
|
25
|
+
LineSpec.new(m[1]&.delete_prefix('./'), from, to)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def self.parse_shard(spec)
|
|
28
|
-
|
|
29
|
-
raise Error, "bad --shard #{spec.inspect} (expected I/N, 1 <= I <= N)" unless
|
|
29
|
+
i, n = spec.match(%r{\A(\d+)/(\d+)\z})&.captures&.map(&:to_i)
|
|
30
|
+
raise Error, "bad --shard #{spec.inspect} (expected I/N, 1 <= I <= N)" unless i&.between?(1, n)
|
|
30
31
|
|
|
31
|
-
[
|
|
32
|
+
[i, n]
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
# lines / exclude_lines: LineSpec objects (see parse_line_spec). shard: [i, n] or nil.
|
|
34
36
|
def initialize(root:, patterns: [], since: nil, methods: [], exclude_methods: [], lines: [], exclude_lines: [],
|
|
35
37
|
shard: nil)
|
|
36
38
|
@root = File.expand_path(root)
|
|
@@ -38,8 +40,8 @@ module Mutaruby
|
|
|
38
40
|
@since = since
|
|
39
41
|
@methods = methods
|
|
40
42
|
@exclude_methods = exclude_methods
|
|
41
|
-
@lines = lines
|
|
42
|
-
@exclude_lines = exclude_lines
|
|
43
|
+
@lines = lines
|
|
44
|
+
@exclude_lines = exclude_lines
|
|
43
45
|
@shard = shard
|
|
44
46
|
end
|
|
45
47
|
|
|
@@ -48,19 +50,16 @@ module Mutaruby
|
|
|
48
50
|
@methods.empty? && @exclude_methods.empty? && @lines.empty? && @exclude_lines.empty? && @shard.nil?
|
|
49
51
|
end
|
|
50
52
|
|
|
51
|
-
# Sorted paths relative to the root.
|
|
53
|
+
# Sorted paths of the matching files, relative to the root.
|
|
52
54
|
def files
|
|
53
|
-
found = @patterns.flat_map
|
|
54
|
-
full = File.expand_path(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Dir.glob(pat, base: @root).select { |f| File.file?(File.join(@root, f)) }
|
|
59
|
-
end
|
|
55
|
+
found = @patterns.flat_map { |pat| Dir.glob(pat, base: @root) }.map do |f|
|
|
56
|
+
full = File.expand_path(f, @root)
|
|
57
|
+
raise Error, "#{f} is outside the project root #{@root}" unless full.start_with?("#{@root}/")
|
|
58
|
+
|
|
59
|
+
full.delete_prefix("#{@root}/")
|
|
60
60
|
end
|
|
61
|
-
found = found.
|
|
62
|
-
found
|
|
63
|
-
found
|
|
61
|
+
found = found.select { |f| File.file?(File.join(@root, f)) }.uniq.sort
|
|
62
|
+
@since ? found & changed_files : found
|
|
64
63
|
end
|
|
65
64
|
|
|
66
65
|
def filter(mutations)
|
|
@@ -73,35 +72,27 @@ module Mutaruby
|
|
|
73
72
|
return kept unless @shard
|
|
74
73
|
|
|
75
74
|
i, n = @shard
|
|
76
|
-
kept.
|
|
75
|
+
kept.select.with_index { |_, idx| idx % n == i - 1 }
|
|
77
76
|
end
|
|
78
77
|
|
|
79
78
|
private
|
|
80
79
|
|
|
81
|
-
def relative(full)
|
|
82
|
-
full.delete_prefix("#{@root}/")
|
|
83
|
-
end
|
|
84
|
-
|
|
85
80
|
# A pattern matches the qualified name ("Calc#add", "Calc.build", "Calc#*") or the bare method name ("add").
|
|
81
|
+
# Code outside any method matches no pattern.
|
|
86
82
|
def method_match?(pattern, name)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
bare = name.split(/[#.]/).last
|
|
90
|
-
File.fnmatch?(pattern, name) || File.fnmatch?(pattern, bare)
|
|
83
|
+
name ? File.fnmatch?(pattern, name) || File.fnmatch?(pattern, name.split(/[#.]/).last) : false
|
|
91
84
|
end
|
|
92
85
|
|
|
93
86
|
# Files changed relative to a git ref: committed, staged and unstaged edits, plus new untracked files.
|
|
94
87
|
def changed_files
|
|
95
|
-
|
|
96
|
-
untracked = git('ls-files', '--others', '--exclude-standard')
|
|
97
|
-
(diff + untracked).uniq
|
|
88
|
+
git('diff', '--name-only', '--relative', @since, '--') + git('ls-files', '--others', '--exclude-standard')
|
|
98
89
|
end
|
|
99
90
|
|
|
100
91
|
def git(*args)
|
|
101
92
|
out, err, st = Open3.capture3('git', '-C', @root, *args)
|
|
102
93
|
raise Error, "git #{args.join(' ')} failed: #{err.strip}" unless st.success?
|
|
103
94
|
|
|
104
|
-
out.
|
|
95
|
+
out.lines(chomp: true)
|
|
105
96
|
end
|
|
106
97
|
end
|
|
107
98
|
end
|
data/lib/mutaruby/version.rb
CHANGED
data/lib/mutaruby.rb
CHANGED
|
@@ -1,19 +1,4 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# mutaruby: mutation testing for Ruby. See README.md.
|
|
4
|
-
module Mutaruby
|
|
5
|
-
class Error < StandardError; end
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
require_relative 'mutaruby/version'
|
|
9
|
-
require_relative 'mutaruby/parsing'
|
|
10
|
-
require_relative 'mutaruby/splicer'
|
|
11
|
-
require_relative 'mutaruby/mutation'
|
|
12
|
-
require_relative 'mutaruby/generator'
|
|
13
|
-
require_relative 'mutaruby/ignore_file'
|
|
14
|
-
require_relative 'mutaruby/sandbox'
|
|
15
|
-
require_relative 'mutaruby/command'
|
|
16
|
-
require_relative 'mutaruby/selection'
|
|
17
|
-
require_relative 'mutaruby/runner'
|
|
18
|
-
require_relative 'mutaruby/reporters'
|
|
19
4
|
require_relative 'mutaruby/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutaruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Atlas Authorization
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- lib/mutaruby.rb
|
|
47
47
|
- lib/mutaruby/cli.rb
|
|
48
48
|
- lib/mutaruby/command.rb
|
|
49
|
+
- lib/mutaruby/error.rb
|
|
49
50
|
- lib/mutaruby/generator.rb
|
|
50
51
|
- lib/mutaruby/ignore_file.rb
|
|
51
52
|
- lib/mutaruby/mutation.rb
|