mutaruby 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 +30 -0
- data/LICENSE +21 -0
- data/README.md +398 -0
- data/exe/mutaruby +6 -0
- data/lib/mutaruby/cli.rb +159 -0
- data/lib/mutaruby/command.rb +101 -0
- data/lib/mutaruby/generator.rb +336 -0
- data/lib/mutaruby/ignore_file.rb +131 -0
- data/lib/mutaruby/mutation.rb +53 -0
- data/lib/mutaruby/parsing.rb +48 -0
- data/lib/mutaruby/reporters.rb +103 -0
- data/lib/mutaruby/runner.rb +181 -0
- data/lib/mutaruby/sandbox.rb +82 -0
- data/lib/mutaruby/selection.rb +107 -0
- data/lib/mutaruby/splicer.rb +27 -0
- data/lib/mutaruby/version.rb +5 -0
- data/lib/mutaruby.rb +19 -0
- metadata +84 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
require_relative 'version'
|
|
6
|
+
|
|
7
|
+
module Mutaruby
|
|
8
|
+
module Reporters
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def score_text(report)
|
|
12
|
+
s = report.score
|
|
13
|
+
s.nil? ? 'n/a' : format('%.2f%%', s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def human(report, io, verbose: false)
|
|
17
|
+
survivors = report.outcomes.select { |o| o.status == :survived }
|
|
18
|
+
if survivors.any?
|
|
19
|
+
io.puts 'Surviving mutants (not detected by the test command):'
|
|
20
|
+
survivors.each do |o|
|
|
21
|
+
m = o.mutation
|
|
22
|
+
io.puts
|
|
23
|
+
io.puts " #{m.location}#{m.method ? " in #{m.method}" : ''} [##{m.id}]"
|
|
24
|
+
io.puts " #{m.label}"
|
|
25
|
+
m.diff(report.sources[m.file]).each_line { |l| io.puts " #{l.chomp}" }
|
|
26
|
+
end
|
|
27
|
+
io.puts
|
|
28
|
+
end
|
|
29
|
+
if verbose
|
|
30
|
+
report.outcomes.select { |o| o.status == :timeout }.each do |o|
|
|
31
|
+
io.puts " timeout: #{o.mutation.location} #{o.mutation.label}"
|
|
32
|
+
end
|
|
33
|
+
report.outcomes.select { |o| o.status == :equivalent }.each do |o|
|
|
34
|
+
io.puts " equivalent: #{o.mutation.location} #{o.mutation.label} (#{o.ignore.reason})"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
report.stale.each { |e| io.puts "warning: STALE ignore entry (no surviving mutant at this site; remove or re-review it): #{e.label}" }
|
|
38
|
+
report.warnings.each { |w| io.puts "warning: #{w}" }
|
|
39
|
+
io.puts 'mutaruby summary'
|
|
40
|
+
io.puts " files: #{report.files.size}"
|
|
41
|
+
io.puts " mutants: #{report.outcomes.size}"
|
|
42
|
+
io.puts " killed: #{report.killed} (#{report.count(:timeout)} by timeout)"
|
|
43
|
+
io.puts " survived: #{report.count(:survived)}"
|
|
44
|
+
io.puts " equivalent: #{report.count(:equivalent)} (ignore file)"
|
|
45
|
+
io.puts " score: #{score_text(report)} = killed / (mutants - equivalent)"
|
|
46
|
+
io.puts format(' time: %.1fs (baseline %.2fs, timeout %ss, jobs %d)', report.seconds,
|
|
47
|
+
report.baseline_seconds, report.timeout, report.jobs)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def json_data(report)
|
|
51
|
+
{
|
|
52
|
+
tool: 'mutaruby', version: VERSION, command: report.command,
|
|
53
|
+
score: report.score&.round(4), complete: report.complete,
|
|
54
|
+
counts: { mutants: report.outcomes.size, killed: report.killed, timeout: report.count(:timeout),
|
|
55
|
+
survived: report.count(:survived), equivalent: report.count(:equivalent) },
|
|
56
|
+
files: report.files, baseline_seconds: report.baseline_seconds.round(3), timeout: report.timeout,
|
|
57
|
+
mutants: report.outcomes.map do |o|
|
|
58
|
+
o.mutation.to_h.merge(status: o.status.to_s, seconds: o.seconds.round(3), ignore_reason: o.ignore&.reason)
|
|
59
|
+
end,
|
|
60
|
+
stale_ignores: report.stale.map(&:to_h).map { |h| h.compact.except(:index) },
|
|
61
|
+
warnings: report.warnings
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def json(report, io)
|
|
66
|
+
io.puts JSON.pretty_generate(json_data(report))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# JUnit-style XML: one testcase per mutant. A survivor is a failure, an equivalent mutant is skipped.
|
|
70
|
+
def junit(report, io)
|
|
71
|
+
x = ->(s) { CGI.escapeHTML(s.to_s) }
|
|
72
|
+
failures = report.count(:survived)
|
|
73
|
+
io.puts '<?xml version="1.0" encoding="UTF-8"?>'
|
|
74
|
+
io.puts %(<testsuites name="mutaruby" tests="#{report.outcomes.size}" failures="#{failures}" time="#{report.seconds.round(3)}">)
|
|
75
|
+
io.puts %( <testsuite name="mutaruby" tests="#{report.outcomes.size}" failures="#{failures}" errors="0" ) +
|
|
76
|
+
%(skipped="#{report.count(:equivalent)}" time="#{report.seconds.round(3)}">)
|
|
77
|
+
io.puts %( <properties><property name="score" value="#{x.call(score_text(report))}"/></properties>)
|
|
78
|
+
report.outcomes.each do |o|
|
|
79
|
+
m = o.mutation
|
|
80
|
+
name = "##{m.id} #{m.location} #{m.label}"
|
|
81
|
+
io.print %( <testcase classname="#{x.call(m.file)}" name="#{x.call(name)}" time="#{o.seconds.round(3)}")
|
|
82
|
+
case o.status
|
|
83
|
+
when :survived
|
|
84
|
+
io.puts '>'
|
|
85
|
+
io.puts %( <failure message="#{x.call("survived: #{m.label}")}" type="survived">#{x.call(m.diff(report.sources[m.file]))}</failure>)
|
|
86
|
+
io.puts ' </testcase>'
|
|
87
|
+
when :equivalent
|
|
88
|
+
io.puts '>'
|
|
89
|
+
io.puts %( <skipped message="#{x.call("equivalent: #{o.ignore.reason}")}"/>)
|
|
90
|
+
io.puts ' </testcase>'
|
|
91
|
+
when :timeout
|
|
92
|
+
io.puts '>'
|
|
93
|
+
io.puts ' <system-out>killed by timeout</system-out>'
|
|
94
|
+
io.puts ' </testcase>'
|
|
95
|
+
else
|
|
96
|
+
io.puts '/>'
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
io.puts ' </testsuite>'
|
|
100
|
+
io.puts '</testsuites>'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require_relative 'generator'
|
|
5
|
+
require_relative 'ignore_file'
|
|
6
|
+
require_relative 'sandbox'
|
|
7
|
+
require_relative 'command'
|
|
8
|
+
require_relative 'selection'
|
|
9
|
+
|
|
10
|
+
module Mutaruby
|
|
11
|
+
class BaselineFailed < Error; end
|
|
12
|
+
|
|
13
|
+
# Generates the mutants, checks the baseline, runs every mutant and collects the verdicts.
|
|
14
|
+
class Runner
|
|
15
|
+
Outcome = Struct.new(:mutation, :status, :seconds, :ignore, keyword_init: true) do
|
|
16
|
+
# :killed, :timeout (also a kill), :survived, :equivalent (survived, listed in the ignore file)
|
|
17
|
+
def killed? = %i[killed timeout].include?(status)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Report = Struct.new(:root, :command, :files, :outcomes, :sources, :stale, :warnings, :baseline_seconds, :timeout,
|
|
21
|
+
:jobs, :seconds, :complete, keyword_init: true) do
|
|
22
|
+
def count(status) = outcomes.count { |o| o.status == status }
|
|
23
|
+
def killed = count(:killed) + count(:timeout)
|
|
24
|
+
|
|
25
|
+
# killed / (killed + survived); equivalents are left out of both. nil when nothing was scored.
|
|
26
|
+
def score
|
|
27
|
+
scored = killed + count(:survived)
|
|
28
|
+
scored.zero? ? nil : (100.0 * killed / scored)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Options = Struct.new(:root, :command, :patterns, :since, :jobs, :timeout, :timeout_factor, :methods,
|
|
33
|
+
:exclude_methods, :lines, :exclude_lines, :shard, :ignore_file, :operators, :copy_excludes,
|
|
34
|
+
:progress, :keyword_init => true)
|
|
35
|
+
|
|
36
|
+
def initialize(opts, out: $stderr)
|
|
37
|
+
@o = opts
|
|
38
|
+
@out = out
|
|
39
|
+
@root = File.expand_path(opts.root || Dir.pwd)
|
|
40
|
+
@warnings = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Mutants of the selected files after every filter, plus what is needed to report them.
|
|
44
|
+
def plan
|
|
45
|
+
@plan ||= begin
|
|
46
|
+
sel = selection
|
|
47
|
+
files = sel.files
|
|
48
|
+
sources = files.to_h { |f| [f, File.read(File.join(@root, f), encoding: 'UTF-8')] }
|
|
49
|
+
all = files.flat_map do |f|
|
|
50
|
+
g = Generator.new(sources[f], path: File.join(@root, f), rel: f, operators: operator_set)
|
|
51
|
+
ms = g.mutations
|
|
52
|
+
@warnings.concat(g.errors)
|
|
53
|
+
ms
|
|
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? }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run
|
|
66
|
+
t0 = monotonic
|
|
67
|
+
p = plan
|
|
68
|
+
sandboxes = []
|
|
69
|
+
sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes || [])
|
|
70
|
+
cmd = Command.new(@o.command, root: @root)
|
|
71
|
+
base = cmd.run(sandboxes[0].dir, capture: true)
|
|
72
|
+
unless base.passed?
|
|
73
|
+
raise BaselineFailed, "the test command fails on the UNMUTATED code (#{base.timeout? ? 'timed out' : "exit #{base.exitstatus}"}); " \
|
|
74
|
+
"every mutant would count as killed, so nothing would be measured.\n" \
|
|
75
|
+
"command: #{@o.command}\n--- output (tail) ---\n#{base.output}"
|
|
76
|
+
end
|
|
77
|
+
timeout = @o.timeout || ((base.seconds * (@o.timeout_factor || 3.0)) + 5).ceil
|
|
78
|
+
outcomes = execute(p, cmd, sandboxes, timeout)
|
|
79
|
+
stale = []
|
|
80
|
+
if p[:ignore]
|
|
81
|
+
matched = outcomes.select { |o| o.status == :equivalent }.map(&:ignore).to_set
|
|
82
|
+
stale = p[:complete] ? p[:ignore].stale(matched, p[:files]) : []
|
|
83
|
+
end
|
|
84
|
+
Report.new(root: @root, command: @o.command, files: p[:files], outcomes: outcomes, sources: p[:sources],
|
|
85
|
+
stale: stale, warnings: @warnings, baseline_seconds: base.seconds, timeout: timeout,
|
|
86
|
+
jobs: jobs, seconds: monotonic - t0, complete: p[:complete])
|
|
87
|
+
rescue Interrupt, SignalException
|
|
88
|
+
cmd&.kill_all
|
|
89
|
+
raise
|
|
90
|
+
ensure
|
|
91
|
+
sandboxes&.each(&:cleanup)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def jobs
|
|
97
|
+
[@o.jobs || 1, 1].max
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def selection
|
|
101
|
+
Selection.new(root: @root, patterns: @o.patterns || [], since: @o.since, methods: @o.methods || [],
|
|
102
|
+
exclude_methods: @o.exclude_methods || [], lines: @o.lines || [],
|
|
103
|
+
exclude_lines: @o.exclude_lines || [], shard: @o.shard)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def operator_set
|
|
107
|
+
return nil unless @o.operators
|
|
108
|
+
|
|
109
|
+
@o.operators.to_set
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def load_ignore
|
|
113
|
+
path = @o.ignore_file
|
|
114
|
+
default = File.join(@root, '.mutaruby-ignore.yml')
|
|
115
|
+
path ||= default if File.exist?(default)
|
|
116
|
+
return nil unless path
|
|
117
|
+
|
|
118
|
+
raise Error, "ignore file not found: #{path}" unless File.exist?(path)
|
|
119
|
+
|
|
120
|
+
IgnoreFile.load(path)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def execute(plan, cmd, sandboxes, timeout)
|
|
124
|
+
queue = Queue.new
|
|
125
|
+
plan[:mutations].each { |m| queue << m }
|
|
126
|
+
n = [jobs, plan[:mutations].size].min
|
|
127
|
+
(sandboxes.size...n).each { sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes || []) }
|
|
128
|
+
results = []
|
|
129
|
+
lock = Mutex.new
|
|
130
|
+
threads = Array.new(n) do |w|
|
|
131
|
+
Thread.new do
|
|
132
|
+
box = sandboxes[w]
|
|
133
|
+
loop do
|
|
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)
|
|
151
|
+
lock.synchronize do
|
|
152
|
+
results << o
|
|
153
|
+
progress(o)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
begin
|
|
159
|
+
threads.each(&:join)
|
|
160
|
+
rescue Exception # rubocop:disable Lint/RescueException -- stop the workers, then re-raise
|
|
161
|
+
threads.each(&:kill)
|
|
162
|
+
raise
|
|
163
|
+
end
|
|
164
|
+
@out.puts if @o.progress && results.any?
|
|
165
|
+
results.sort_by { |o| o.mutation.id }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
MARK = { killed: '.', timeout: 'T', survived: 'S', equivalent: 'e' }.freeze
|
|
169
|
+
|
|
170
|
+
def progress(outcome)
|
|
171
|
+
return unless @o.progress
|
|
172
|
+
|
|
173
|
+
@out.print(MARK[outcome.status])
|
|
174
|
+
@out.flush
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def monotonic
|
|
178
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'find'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module Mutaruby
|
|
8
|
+
# A private copy of the project. Every test-command run happens inside one, so the user's files are never written:
|
|
9
|
+
# a crash, a timeout or a Ctrl-C in the middle of a run cannot leave a mutant behind in the working tree.
|
|
10
|
+
#
|
|
11
|
+
# Each worker owns one sandbox. A mutant is written into it, the test command runs, and the original file content
|
|
12
|
+
# is written back before the next mutant.
|
|
13
|
+
class Sandbox
|
|
14
|
+
DEFAULT_EXCLUDES = %w[.git coverage].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :dir
|
|
17
|
+
|
|
18
|
+
def initialize(root, excludes: [], prefix: 'mutaruby-')
|
|
19
|
+
@root = File.expand_path(root)
|
|
20
|
+
@excludes = DEFAULT_EXCLUDES + excludes
|
|
21
|
+
@prefix = prefix
|
|
22
|
+
@base = Dir.mktmpdir(prefix)
|
|
23
|
+
@dir = File.join(@base, File.basename(@root))
|
|
24
|
+
copy_tree
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def path(rel)
|
|
28
|
+
File.join(@dir, rel)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Writes `text` to `rel` inside the sandbox, yields, and restores the original bytes afterwards (also on error).
|
|
32
|
+
def with_file(rel, text, original)
|
|
33
|
+
target = path(rel)
|
|
34
|
+
File.binwrite(target, text)
|
|
35
|
+
yield
|
|
36
|
+
ensure
|
|
37
|
+
File.binwrite(target, original) if target
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cleanup
|
|
41
|
+
FileUtils.rm_rf(@base)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def excluded?(rel)
|
|
47
|
+
@excludes.any? do |pat|
|
|
48
|
+
File.fnmatch?(pat, rel, File::FNM_PATHNAME | File::FNM_DOTMATCH) ||
|
|
49
|
+
File.fnmatch?(pat, File.basename(rel), File::FNM_DOTMATCH)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 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.
|
|
55
|
+
def sandbox?(path)
|
|
56
|
+
File.basename(path).start_with?(@prefix) && File.directory?(path) &&
|
|
57
|
+
File.realpath(File.dirname(path)) == File.realpath(File.dirname(@base))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def copy_tree
|
|
61
|
+
FileUtils.mkdir_p(@dir)
|
|
62
|
+
Find.find(@root) do |src|
|
|
63
|
+
next if src == @root
|
|
64
|
+
|
|
65
|
+
rel = src.delete_prefix("#{@root}/")
|
|
66
|
+
if excluded?(rel) || sandbox?(src)
|
|
67
|
+
Find.prune
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
dest = File.join(@dir, rel)
|
|
71
|
+
st = File.lstat(src)
|
|
72
|
+
if st.symlink?
|
|
73
|
+
File.symlink(File.readlink(src), dest)
|
|
74
|
+
elsif st.directory?
|
|
75
|
+
FileUtils.mkdir_p(dest)
|
|
76
|
+
elsif st.file?
|
|
77
|
+
FileUtils.cp(src, dest, preserve: true)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Mutaruby
|
|
6
|
+
# Which files to mutate and which of their mutants to run.
|
|
7
|
+
class Selection
|
|
8
|
+
DEFAULT_PATTERNS = ['lib/**/*.rb'].freeze
|
|
9
|
+
|
|
10
|
+
LineSpec = Struct.new(:file, :from, :to) do
|
|
11
|
+
def cover?(mutation)
|
|
12
|
+
(file.nil? || file == mutation.file) && mutation.lineno.between?(from, to)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.parse_line_spec(spec)
|
|
17
|
+
m = spec.match(/\A(?:(.+):)?(\d+)(?:-(\d+))?\z/)
|
|
18
|
+
raise Error, "bad line spec #{spec.inspect} (expected [FILE:]LINE or [FILE:]FROM-TO)" unless m
|
|
19
|
+
|
|
20
|
+
from = m[2].to_i
|
|
21
|
+
to = (m[3] || m[2]).to_i
|
|
22
|
+
raise Error, "bad line spec #{spec.inspect} (FROM > TO)" if from > to
|
|
23
|
+
|
|
24
|
+
LineSpec.new(m[1]&.sub(%r{\A\./}, ''), from, to)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.parse_shard(spec)
|
|
28
|
+
m = spec.to_s.match(%r{\A(\d+)/(\d+)\z})
|
|
29
|
+
raise Error, "bad --shard #{spec.inspect} (expected I/N, 1 <= I <= N)" unless m && m[2].to_i.positive? && m[1].to_i.between?(1, m[2].to_i)
|
|
30
|
+
|
|
31
|
+
[m[1].to_i, m[2].to_i]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(root:, patterns: [], since: nil, methods: [], exclude_methods: [], lines: [], exclude_lines: [],
|
|
35
|
+
shard: nil)
|
|
36
|
+
@root = File.expand_path(root)
|
|
37
|
+
@patterns = patterns.empty? ? DEFAULT_PATTERNS : patterns
|
|
38
|
+
@since = since
|
|
39
|
+
@methods = methods
|
|
40
|
+
@exclude_methods = exclude_methods
|
|
41
|
+
@lines = lines.map { |s| s.is_a?(LineSpec) ? s : self.class.parse_line_spec(s) }
|
|
42
|
+
@exclude_lines = exclude_lines.map { |s| s.is_a?(LineSpec) ? s : self.class.parse_line_spec(s) }
|
|
43
|
+
@shard = shard
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when every mutant of each selected file is run (so stale ignore entries can be detected).
|
|
47
|
+
def complete?
|
|
48
|
+
@methods.empty? && @exclude_methods.empty? && @lines.empty? && @exclude_lines.empty? && @shard.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Sorted paths relative to the root.
|
|
52
|
+
def files
|
|
53
|
+
found = @patterns.flat_map do |pat|
|
|
54
|
+
full = File.expand_path(pat, @root)
|
|
55
|
+
if File.file?(full)
|
|
56
|
+
[relative(full)]
|
|
57
|
+
else
|
|
58
|
+
Dir.glob(pat, base: @root).select { |f| File.file?(File.join(@root, f)) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
found = found.map { |f| f.sub(%r{\A\./}, '') }.uniq.sort
|
|
62
|
+
found &= changed_files if @since
|
|
63
|
+
found
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def filter(mutations)
|
|
67
|
+
kept = mutations.select do |m|
|
|
68
|
+
(@methods.empty? || @methods.any? { |p| method_match?(p, m.method) }) &&
|
|
69
|
+
@exclude_methods.none? { |p| method_match?(p, m.method) } &&
|
|
70
|
+
(@lines.empty? || @lines.any? { |s| s.cover?(m) }) &&
|
|
71
|
+
@exclude_lines.none? { |s| s.cover?(m) }
|
|
72
|
+
end
|
|
73
|
+
return kept unless @shard
|
|
74
|
+
|
|
75
|
+
i, n = @shard
|
|
76
|
+
kept.each_with_index.select { |_, idx| idx % n == i - 1 }.map(&:first)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def relative(full)
|
|
82
|
+
full.delete_prefix("#{@root}/")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A pattern matches the qualified name ("Calc#add", "Calc.build", "Calc#*") or the bare method name ("add").
|
|
86
|
+
def method_match?(pattern, name)
|
|
87
|
+
return false if name.nil?
|
|
88
|
+
|
|
89
|
+
bare = name.split(/[#.]/).last
|
|
90
|
+
File.fnmatch?(pattern, name) || File.fnmatch?(pattern, bare)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Files changed relative to a git ref: committed, staged and unstaged edits, plus new untracked files.
|
|
94
|
+
def changed_files
|
|
95
|
+
diff = git('diff', '--name-only', '--relative', @since, '--')
|
|
96
|
+
untracked = git('ls-files', '--others', '--exclude-standard')
|
|
97
|
+
(diff + untracked).uniq
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def git(*args)
|
|
101
|
+
out, err, st = Open3.capture3('git', '-C', @root, *args)
|
|
102
|
+
raise Error, "git #{args.join(' ')} failed: #{err.strip}" unless st.success?
|
|
103
|
+
|
|
104
|
+
out.split("\n").reject(&:empty?)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutaruby
|
|
4
|
+
# Raised when a splice would not replace exactly the node's own source. Aborts the whole run: a mutant placed at
|
|
5
|
+
# the wrong offset tests nothing, and if it "survives" it reports a test gap that does not exist.
|
|
6
|
+
class SpliceError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Replaces source[begin_pos...end_pos] with a replacement.
|
|
9
|
+
#
|
|
10
|
+
# The `parser` gem reports CHARACTER offsets, so the slice is taken by characters. Slicing by bytes would shift every
|
|
11
|
+
# mutant after the first multi-byte character in the file (an accented letter in a comment is enough) onto the wrong
|
|
12
|
+
# text. The self-check makes that class of bug (bytes vs characters, a stale buffer, a wrong range) fail loudly:
|
|
13
|
+
# the slice being replaced must be exactly the node's own source text, or nothing is spliced.
|
|
14
|
+
module Splicer
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def splice(source, begin_pos, end_pos, expected, replacement)
|
|
18
|
+
actual = source[begin_pos...end_pos]
|
|
19
|
+
unless actual == expected
|
|
20
|
+
raise SpliceError, "splice self-check failed at offsets #{begin_pos}...#{end_pos}: " \
|
|
21
|
+
"the slice is #{actual.inspect}, the node's source is #{expected.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
source[0...begin_pos] + replacement + source[end_pos..]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/mutaruby.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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
|
+
require_relative 'mutaruby/cli'
|
metadata
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mutaruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Atlas Authorization
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: parser
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '3.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4'
|
|
32
|
+
description: mutaruby changes your code in small ways (mutants) and runs your test
|
|
33
|
+
command against each change. A mutant your tests do not notice is a gap in your
|
|
34
|
+
tests. Works with any test command (rspec, minitest, rake), runs every mutant in
|
|
35
|
+
a fresh process inside a private copy of the project, and reports a mutation score
|
|
36
|
+
as text, JSON or JUnit XML.
|
|
37
|
+
executables:
|
|
38
|
+
- mutaruby
|
|
39
|
+
extensions: []
|
|
40
|
+
extra_rdoc_files: []
|
|
41
|
+
files:
|
|
42
|
+
- CHANGELOG.md
|
|
43
|
+
- LICENSE
|
|
44
|
+
- README.md
|
|
45
|
+
- exe/mutaruby
|
|
46
|
+
- lib/mutaruby.rb
|
|
47
|
+
- lib/mutaruby/cli.rb
|
|
48
|
+
- lib/mutaruby/command.rb
|
|
49
|
+
- lib/mutaruby/generator.rb
|
|
50
|
+
- lib/mutaruby/ignore_file.rb
|
|
51
|
+
- lib/mutaruby/mutation.rb
|
|
52
|
+
- lib/mutaruby/parsing.rb
|
|
53
|
+
- lib/mutaruby/reporters.rb
|
|
54
|
+
- lib/mutaruby/runner.rb
|
|
55
|
+
- lib/mutaruby/sandbox.rb
|
|
56
|
+
- lib/mutaruby/selection.rb
|
|
57
|
+
- lib/mutaruby/splicer.rb
|
|
58
|
+
- lib/mutaruby/version.rb
|
|
59
|
+
homepage: https://github.com/Atlas-Authorization/mutaruby
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://github.com/Atlas-Authorization/mutaruby
|
|
64
|
+
source_code_uri: https://github.com/Atlas-Authorization/mutaruby
|
|
65
|
+
changelog_uri: https://github.com/Atlas-Authorization/mutaruby/blob/main/CHANGELOG.md
|
|
66
|
+
bug_tracker_uri: https://github.com/Atlas-Authorization/mutaruby/issues
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.1'
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 4.0.16
|
|
82
|
+
specification_version: 4
|
|
83
|
+
summary: Free, MIT-licensed mutation testing for Ruby
|
|
84
|
+
test_files: []
|