saboteur 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +145 -0
- data/exe/saboteur +12 -0
- data/lib/saboteur/cli.rb +112 -0
- data/lib/saboteur/config.rb +133 -0
- data/lib/saboteur/engine/boot.rb +83 -0
- data/lib/saboteur/engine/candidates.rb +63 -0
- data/lib/saboteur/engine/coverage_store.rb +99 -0
- data/lib/saboteur/engine/diagnostics.rb +37 -0
- data/lib/saboteur/engine/diff_scope.rb +32 -0
- data/lib/saboteur/engine/distribution.rb +74 -0
- data/lib/saboteur/engine/result_cache.rb +93 -0
- data/lib/saboteur/engine/scheduler.rb +215 -0
- data/lib/saboteur/engine/subject_selection.rb +63 -0
- data/lib/saboteur/engine/suite_files.rb +82 -0
- data/lib/saboteur/engine/verdict_check.rb +78 -0
- data/lib/saboteur/engine.rb +133 -0
- data/lib/saboteur/execution/process_table.rb +177 -0
- data/lib/saboteur/execution/process_tree.rb +99 -0
- data/lib/saboteur/execution/protocol.rb +41 -0
- data/lib/saboteur/execution/reevaluator.rb +144 -0
- data/lib/saboteur/execution/runner.rb +191 -0
- data/lib/saboteur/execution/supervisor.rb +337 -0
- data/lib/saboteur/execution/worker_pool.rb +249 -0
- data/lib/saboteur/frameworks/rails.rb +166 -0
- data/lib/saboteur/hooks.rb +51 -0
- data/lib/saboteur/ignore_list.rb +62 -0
- data/lib/saboteur/integration/base.rb +72 -0
- data/lib/saboteur/integration/rspec.rb +143 -0
- data/lib/saboteur/mutation_source/base.rb +26 -0
- data/lib/saboteur/mutation_source/builtin.rb +232 -0
- data/lib/saboteur/mutation_source/edit.rb +18 -0
- data/lib/saboteur/mutation_source/mutation.rb +28 -0
- data/lib/saboteur/reporter/diff.rb +97 -0
- data/lib/saboteur/reporter/stryker.rb +116 -0
- data/lib/saboteur/reporter/terminal.rb +136 -0
- data/lib/saboteur/result.rb +134 -0
- data/lib/saboteur/selection/coverage_map.rb +113 -0
- data/lib/saboteur/selection/git_diff.rb +64 -0
- data/lib/saboteur/selection/recorder.rb +72 -0
- data/lib/saboteur/subject.rb +166 -0
- data/lib/saboteur/subject_finder.rb +98 -0
- data/lib/saboteur/version.rb +6 -0
- data/lib/saboteur.rb +51 -0
- metadata +130 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "coverage"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
class Engine
|
|
10
|
+
# Returns the coverage map for a run: from the cache when every input is
|
|
11
|
+
# unchanged, otherwise built by running the whole suite once in a
|
|
12
|
+
# supervised child and saved.
|
|
13
|
+
class CoverageStore
|
|
14
|
+
CACHE_INPUTS = %w[integration require rspec_args rails].freeze
|
|
15
|
+
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
16
|
+
LOG = "coverage-build.log"
|
|
17
|
+
|
|
18
|
+
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
|
|
19
|
+
@config = config
|
|
20
|
+
@integration = integration
|
|
21
|
+
@hooks = hooks
|
|
22
|
+
@supervisor = supervisor
|
|
23
|
+
@diagnostics = diagnostics
|
|
24
|
+
@root = File.realpath(config.root)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch(files)
|
|
28
|
+
path = File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
|
|
29
|
+
cached = @config["cache"] ? Selection::CoverageMap.load(path) : nil
|
|
30
|
+
return cached if cached
|
|
31
|
+
|
|
32
|
+
map = build(files)
|
|
33
|
+
map.save(path)
|
|
34
|
+
map
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def cache_key(files)
|
|
40
|
+
inputs = JSON.generate(CACHE_INPUTS.to_h { |key| [key, @config[key]] })
|
|
41
|
+
all = files + @integration.test_files + SuiteFiles.new(@config).files + lockfiles
|
|
42
|
+
Selection::CoverageMap.cache_key(files: all.uniq, extra: inputs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def lockfiles
|
|
46
|
+
LOCKFILES.map { |file| File.join(@root, file) }.select { |file| File.file?(file) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build(files)
|
|
50
|
+
unless Coverage.respond_to?(:state) && Coverage.state == :suspended
|
|
51
|
+
raise Error, "Coverage is #{Coverage.state}; saboteur must set it up before the app loads. " \
|
|
52
|
+
"Run through the saboteur executable and disable SimpleCov for the run."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@diagnostics.inform("building coverage map (runs the whole suite once)")
|
|
56
|
+
result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, files) }
|
|
57
|
+
raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
|
|
58
|
+
raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
|
|
59
|
+
|
|
60
|
+
data = JSON.parse(result.payload)
|
|
61
|
+
raise Error, "coverage map build failed: #{data['error']}" if data["error"]
|
|
62
|
+
|
|
63
|
+
Selection::CoverageMap.from_json(result.payload)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def died(status)
|
|
67
|
+
how = if status&.signaled? then "was killed by signal #{Signal.signame(status.termsig)}"
|
|
68
|
+
else "exited with status #{status&.exitstatus}"
|
|
69
|
+
end
|
|
70
|
+
"the suite #{how} before reporting; its output is in #{log_path.delete_prefix("#{@root}/")}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def log_path
|
|
74
|
+
File.join(@config.path("cache_dir"), LOG)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Runs in the supervised child. The suite's output goes to a log, so a
|
|
78
|
+
# child that dies without reporting can still be diagnosed.
|
|
79
|
+
def record(writer, files)
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(log_path))
|
|
81
|
+
$stdout.reopen(log_path, "w")
|
|
82
|
+
$stdout.sync = true
|
|
83
|
+
$stderr.reopen($stdout)
|
|
84
|
+
@hooks.run(:after_fork)
|
|
85
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
recorder = Selection::Recorder.new(files)
|
|
87
|
+
recorder.start
|
|
88
|
+
Kernel.srand(@config["seed"])
|
|
89
|
+
@integration.run_all(recorder)
|
|
90
|
+
build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
91
|
+
writer.write(recorder.result(build_seconds: build_seconds).to_json)
|
|
92
|
+
writer.close
|
|
93
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
94
|
+
writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
|
|
95
|
+
exit!(1)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# What a run has to say besides results: warnings for the report, the
|
|
7
|
+
# count of mutations dropped for not parsing, and progress marks.
|
|
8
|
+
class Diagnostics
|
|
9
|
+
MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :warnings, :invalid_mutations
|
|
12
|
+
|
|
13
|
+
def initialize(err)
|
|
14
|
+
@err = err
|
|
15
|
+
@warnings = []
|
|
16
|
+
@invalid_mutations = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def warn(message)
|
|
20
|
+
@warnings << message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def count_invalid_mutation
|
|
24
|
+
@invalid_mutations += 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def inform(message)
|
|
28
|
+
@err.puts("saboteur: #{message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def progress(result)
|
|
32
|
+
@err.print(MARKS.fetch(result.status, "?"))
|
|
33
|
+
@err.flush
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Review-shaped diff mode (--since). A reviewer asks "do the tests check
|
|
7
|
+
# what this change did?", so only mutants on changed lines answer it.
|
|
8
|
+
# The coarse mutants of each changed method still run: they ask whether
|
|
9
|
+
# the method is tested at all.
|
|
10
|
+
class DiffScope
|
|
11
|
+
# changed_lines: { "/abs/file.rb" => Set[line numbers] }
|
|
12
|
+
def initialize(changed_lines)
|
|
13
|
+
@changed_lines = changed_lines
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def restrict(subject, candidates)
|
|
17
|
+
changed = @changed_lines.fetch(subject.file, Set.new)
|
|
18
|
+
candidates.select do |mutation, _|
|
|
19
|
+
mutation.coarse || mutation.edits.any? { |edit| edited_lines(subject, edit).any? { changed.include?(_1) } }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def edited_lines(subject, edit)
|
|
26
|
+
first = subject.line_of(edit.start_byte)
|
|
27
|
+
last = subject.line_of([edit.end_byte - 1, edit.start_byte].max)
|
|
28
|
+
(first..last)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Runs each subject's candidates through the scheduler, on one process or
|
|
7
|
+
# on a pool of workers (--jobs N). A worker sends its results back as
|
|
8
|
+
# plain data; the parent rebuilds them against its own copy of the same
|
|
9
|
+
# candidate list, so results look the same either way.
|
|
10
|
+
class Distribution
|
|
11
|
+
def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil)
|
|
12
|
+
@jobs = jobs
|
|
13
|
+
@on_start = on_start
|
|
14
|
+
@scheduler = scheduler
|
|
15
|
+
@diagnostics = diagnostics
|
|
16
|
+
@cache = cache
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# lists: each subject's [[mutation, text]] candidates, in subject order.
|
|
20
|
+
def run(subjects, lists)
|
|
21
|
+
return subjects.zip(lists).flat_map { |subject, list| @scheduler.call(subject, list) } if @jobs <= 1
|
|
22
|
+
|
|
23
|
+
pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start)
|
|
24
|
+
rows = pool.map(subjects.each_index.to_a, weights: lists.map(&:size)) do |i|
|
|
25
|
+
work(subjects[i], lists[i])
|
|
26
|
+
end
|
|
27
|
+
rows.each_with_index.flat_map { |row, i| rebuild(row, subjects[i], lists[i]) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Runs in a worker.
|
|
33
|
+
def work(subject, list)
|
|
34
|
+
before = @diagnostics.warnings.size
|
|
35
|
+
results = @scheduler.call(subject, list)
|
|
36
|
+
{ "warnings" => @diagnostics.warnings.drop(before),
|
|
37
|
+
"results" => results.map { |result| encode(result, list) } }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def encode(result, list)
|
|
41
|
+
{
|
|
42
|
+
"position" => list.index { |mutation, _| mutation.equal?(result.mutation) },
|
|
43
|
+
"status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
|
|
44
|
+
"killed_by" => result.killed_by, "covered_by" => result.covered_by,
|
|
45
|
+
"kill_cause" => result.kill_cause, "reused" => result.reused?
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rebuild(row, subject, list)
|
|
50
|
+
return failed(row["error"], subject, list) if row["error"]
|
|
51
|
+
|
|
52
|
+
@diagnostics.warnings.concat(row["warnings"])
|
|
53
|
+
row["results"].map do |data|
|
|
54
|
+
mutation, text = list.fetch(data["position"])
|
|
55
|
+
result = MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
|
|
56
|
+
mutated_source: text, reason: data["reason"], runtime: data["runtime"],
|
|
57
|
+
killed_by: data["killed_by"], covered_by: data["covered_by"],
|
|
58
|
+
kill_cause: data["kill_cause"], reused: data["reused"])
|
|
59
|
+
@cache&.record(result) unless result.reused?
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The worker died (or raised) while running this subject.
|
|
65
|
+
def failed(message, subject, list)
|
|
66
|
+
@diagnostics.warn("#{subject.id}: #{message}")
|
|
67
|
+
list.map do |mutation, text|
|
|
68
|
+
MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: text,
|
|
69
|
+
reason: message)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
class Engine
|
|
10
|
+
# Opt-in reuse of earlier verdicts (--incremental), like PIT's and
|
|
11
|
+
# Stryker's history files.
|
|
12
|
+
#
|
|
13
|
+
# A verdict is reused only while everything it was computed from is the
|
|
14
|
+
# same: the method's source, the mutant, the set of covering tests, the
|
|
15
|
+
# content of those tests' files, the lockfile, the run's inputs (config,
|
|
16
|
+
# saboteur version). Only killed, survived and timeout are stored;
|
|
17
|
+
# errors and skips always run again.
|
|
18
|
+
#
|
|
19
|
+
# Known hole, the reason this is opt-in: a change in a method this one
|
|
20
|
+
# calls is not seen, so a stored verdict may be stale after it.
|
|
21
|
+
class ResultCache
|
|
22
|
+
FORMAT = 1
|
|
23
|
+
STORED = %i[killed survived timeout].freeze
|
|
24
|
+
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
25
|
+
|
|
26
|
+
# suite_digest: SuiteFiles#digest, so an edit to a spec helper or
|
|
27
|
+
# support file invalidates every verdict.
|
|
28
|
+
def initialize(path:, root:, inputs:, suite_digest: "")
|
|
29
|
+
@path = path
|
|
30
|
+
@root = root
|
|
31
|
+
@inputs = inputs
|
|
32
|
+
@suite_digest = suite_digest
|
|
33
|
+
@entries = load
|
|
34
|
+
@file_digests = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The stored verdict as a hash (status, reason, killed_by, kill_cause,
|
|
38
|
+
# runtime), or nil when it may no longer hold.
|
|
39
|
+
def lookup(subject, mutation, tests)
|
|
40
|
+
entry = @entries[key(subject, mutation)]
|
|
41
|
+
return nil unless entry && entry["fingerprint"] == fingerprint(subject, tests)
|
|
42
|
+
|
|
43
|
+
{ status: entry["status"].to_sym, reason: entry["reason"], killed_by: entry["killed_by"],
|
|
44
|
+
kill_cause: entry["kill_cause"], runtime: entry["runtime"] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record(result)
|
|
48
|
+
return unless STORED.include?(result.status)
|
|
49
|
+
|
|
50
|
+
@entries[key(result.subject, result.mutation)] = {
|
|
51
|
+
"fingerprint" => fingerprint(result.subject, result.covered_by), "status" => result.status.to_s,
|
|
52
|
+
"reason" => result.reason, "killed_by" => result.killed_by, "kill_cause" => result.kill_cause,
|
|
53
|
+
"runtime" => result.runtime
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def save
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
59
|
+
File.write(@path, JSON.generate(format: FORMAT, entries: @entries))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def load
|
|
65
|
+
data = JSON.parse(File.read(@path))
|
|
66
|
+
data["format"] == FORMAT ? data["entries"] : {}
|
|
67
|
+
rescue SystemCallError, JSON::ParserError
|
|
68
|
+
{}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def key(subject, mutation)
|
|
72
|
+
"#{subject.id} #{mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fingerprint(subject, tests)
|
|
76
|
+
digest = Digest::SHA256.new
|
|
77
|
+
digest << VERSION << "\0" << @inputs << "\0" << @suite_digest << "\0" << subject.source << "\0"
|
|
78
|
+
tests.sort.each { |test| digest << test << "\0" << file_digest(test_file(test)) << "\0" }
|
|
79
|
+
LOCKFILES.each { |name| digest << file_digest(File.join(@root, name)) << "\0" }
|
|
80
|
+
digest.hexdigest
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# RSpec ids name their file: "./spec/a_spec.rb[1:2]".
|
|
84
|
+
def test_file(test)
|
|
85
|
+
File.expand_path(test[/\A(.+?\.rb)(?:\[|:|\z)/, 1] || test, @root)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def file_digest(path)
|
|
89
|
+
@file_digests[path] ||= File.file?(path) ? Digest::SHA256.file(path).hexdigest : "missing"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Runs one subject's candidates and returns their results. This is the
|
|
7
|
+
# unit of work parallel workers will share out.
|
|
8
|
+
#
|
|
9
|
+
# Order of business: skip what cannot be run (refinements, not loaded,
|
|
10
|
+
# shadowed, no covering test); coarse mutants; fine mutants only if no
|
|
11
|
+
# coarse mutant survived; then check every kill and timeout against the
|
|
12
|
+
# unchanged method (VerdictCheck). Each mutant runs only the tests that
|
|
13
|
+
# executed its edit, likeliest killers first, with a timeout from those
|
|
14
|
+
# tests' recorded durations.
|
|
15
|
+
class Scheduler
|
|
16
|
+
# A subject to run, with what its mutants need. killers counts the
|
|
17
|
+
# tests that killed earlier mutants of this subject; slowdown is how
|
|
18
|
+
# much slower than recorded its tests ran in a child (see calibrate).
|
|
19
|
+
Plan = Struct.new(:subject, :tests, :killers, :slowdown, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# Tests that killed more earlier mutants first; otherwise keep the order.
|
|
22
|
+
def self.prioritize(tests, killers)
|
|
23
|
+
tests.sort_by.with_index { |id, index| [-killers.fetch(id, 0), index] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil)
|
|
27
|
+
@config = config
|
|
28
|
+
@runner = runner
|
|
29
|
+
@map = map
|
|
30
|
+
@reevaluator = reevaluator
|
|
31
|
+
@diagnostics = diagnostics
|
|
32
|
+
@cache = cache
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(subject, candidates)
|
|
36
|
+
return [] if candidates.empty?
|
|
37
|
+
|
|
38
|
+
reason = subject.skip_reason || load_problem(subject)
|
|
39
|
+
return skip_all(candidates, subject, reason) if reason
|
|
40
|
+
|
|
41
|
+
tests = @map.tests_for(subject)
|
|
42
|
+
return skip_all(candidates, subject, "no coverage") if tests.empty?
|
|
43
|
+
|
|
44
|
+
plan = Plan.new(subject:, tests:, killers: Hash.new(0), slowdown: 1.0)
|
|
45
|
+
reused = all_reused(plan, candidates)
|
|
46
|
+
return reused if reused
|
|
47
|
+
|
|
48
|
+
results = run_candidates(plan, candidates) do |mutation, text|
|
|
49
|
+
reuse(plan, mutation, text) || execute(plan, mutation, text)
|
|
50
|
+
end
|
|
51
|
+
checked(subject, results)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The per-mutant timeout: timeout_factor times what the tests took
|
|
55
|
+
# while the map was built (times the subject's measured slowdown), and
|
|
56
|
+
# never less than timeout_min.
|
|
57
|
+
def timeout_for(tests, slowdown = 1.0)
|
|
58
|
+
recorded = @map.recorded_runtime(tests)
|
|
59
|
+
return @config["baseline_timeout"] unless recorded
|
|
60
|
+
|
|
61
|
+
deadline(recorded * slowdown)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Verdicts that did not hold up are warned about once per subject;
|
|
67
|
+
# only checked verdicts are stored for --incremental.
|
|
68
|
+
def checked(subject, results)
|
|
69
|
+
run_unchanged = lambda do |tests, timeout|
|
|
70
|
+
@runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: timeout)
|
|
71
|
+
.tap { |outcome| note_leftovers(subject, "verdict check", outcome) }
|
|
72
|
+
end
|
|
73
|
+
check = VerdictCheck.new(run_unchanged: run_unchanged, baseline_timeout: @config["baseline_timeout"])
|
|
74
|
+
final = check.call(results)
|
|
75
|
+
final.reject(&:reused?).each { |result| @cache&.record(result) }
|
|
76
|
+
overturned = final.zip(results).count { |after, before| !after.equal?(before) }
|
|
77
|
+
if overturned.positive?
|
|
78
|
+
@diagnostics.warn("#{subject.id}: #{overturned} verdict(s) failed with the method unchanged")
|
|
79
|
+
end
|
|
80
|
+
final
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# When every verdict can be reused, nothing forks: not even the neutral
|
|
84
|
+
# check. Returns the results, or nil at the first one that must run.
|
|
85
|
+
def all_reused(plan, candidates)
|
|
86
|
+
return nil unless @cache
|
|
87
|
+
|
|
88
|
+
catch(:must_run) do
|
|
89
|
+
run_candidates(plan, candidates) { |mutation, text| reuse(plan, mutation, text) || throw(:must_run) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run_candidates(plan, candidates, &run_one)
|
|
94
|
+
coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
|
|
95
|
+
results = coarse.map { |mutation, text| run_one.call(mutation, text) }
|
|
96
|
+
# A surviving coarse mutant means nothing checks the body, so the fine
|
|
97
|
+
# mutants would only add noise. Not so when a rescue or else can
|
|
98
|
+
# return a value of its own: fine mutants that make the body raise
|
|
99
|
+
# can still be caught.
|
|
100
|
+
if results.any?(&:survived?) && !plan.subject.recovers?
|
|
101
|
+
return results + skip_all(fine, plan.subject, "coarse mutant survived", covered_by: plan.tests)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
results + fine.map { |mutation, text| run_one.call(mutation, text) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A stored verdict that still holds, as a result; nil when it must run.
|
|
108
|
+
def reuse(plan, mutation, text)
|
|
109
|
+
return nil unless @cache
|
|
110
|
+
|
|
111
|
+
tests = tests_for(plan, mutation)
|
|
112
|
+
return skip_all([[mutation, text]], plan.subject, "no coverage").first if tests.empty?
|
|
113
|
+
|
|
114
|
+
hit = @cache.lookup(plan.subject, mutation, tests)
|
|
115
|
+
return nil unless hit
|
|
116
|
+
|
|
117
|
+
MutantResult.new(subject: plan.subject, mutation: mutation, status: hit[:status], mutated_source: text,
|
|
118
|
+
reason: hit[:reason], runtime: hit[:runtime], killed_by: hit[:killed_by],
|
|
119
|
+
covered_by: tests, kill_cause: hit[:kill_cause], reused: true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def execute(plan, mutation, text)
|
|
123
|
+
tests = tests_for(plan, mutation)
|
|
124
|
+
return skip_all([[mutation, text]], plan.subject, "no coverage").first if tests.empty?
|
|
125
|
+
|
|
126
|
+
timeout = timeout_for(tests, plan.slowdown)
|
|
127
|
+
outcome = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: timeout, mutation: mutation)
|
|
128
|
+
outcome = calibrate(plan, mutation, text, tests, timeout) if outcome.status == :timeout
|
|
129
|
+
result = MutantResult.new(subject: plan.subject, mutation: mutation, status: outcome.status,
|
|
130
|
+
mutated_source: text, reason: outcome.message, runtime: outcome.runtime,
|
|
131
|
+
killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause)
|
|
132
|
+
outcome.failed_ids.each { |id| plan.killers[id] += 1 }
|
|
133
|
+
note_leftovers(plan.subject, "#{mutation.operator} #{result.digest}", outcome)
|
|
134
|
+
@diagnostics.progress(result)
|
|
135
|
+
result
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The tests that ran the edited code (a coarse edit spans the whole
|
|
139
|
+
# body, so it gets every test of the method), prior killers first, then
|
|
140
|
+
# fastest-first.
|
|
141
|
+
def tests_for(plan, mutation)
|
|
142
|
+
line_numbers = mutation.edits.flat_map { |edit| plan.subject.coverage_lines_for(edit) }.uniq
|
|
143
|
+
tests = plan.tests & @map.tests_for_lines(plan.subject.file, line_numbers)
|
|
144
|
+
self.class.prioritize(tests, plan.killers)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def deadline(runtime)
|
|
148
|
+
[@config["timeout_factor"] * runtime, @config["timeout_min"]].max
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Recorded durations come from one warm process; a fresh child can be
|
|
152
|
+
# slower (lazy loading, first queries). So a timeout is checked at
|
|
153
|
+
# once: the unchanged method runs the same tests with
|
|
154
|
+
# baseline_timeout. If the deadline from that real runtime is longer,
|
|
155
|
+
# the mutant runs again with it, and later mutants of the subject get
|
|
156
|
+
# the measured slowdown. A timeout that remains is the mutant's own.
|
|
157
|
+
def calibrate(plan, mutation, text, tests, timeout)
|
|
158
|
+
unchanged = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
|
|
159
|
+
timeout: @config["baseline_timeout"])
|
|
160
|
+
note_leftovers(plan.subject, "timeout check", unchanged)
|
|
161
|
+
return unchanged_failed(unchanged) unless unchanged.status == :survived
|
|
162
|
+
|
|
163
|
+
recorded = @map.recorded_runtime(tests)
|
|
164
|
+
plan.slowdown = [plan.slowdown, unchanged.runtime / recorded].max if recorded&.positive?
|
|
165
|
+
longer = deadline(unchanged.runtime)
|
|
166
|
+
return Execution::Runner::Outcome.new(status: :timeout, runtime: timeout) if longer <= timeout
|
|
167
|
+
|
|
168
|
+
@runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: longer, mutation: mutation)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def unchanged_failed(unchanged)
|
|
172
|
+
how = unchanged.status == :timeout ? "time out" : "fail"
|
|
173
|
+
hint = unchanged.status == :timeout ? " (raise baseline_timeout)" : ""
|
|
174
|
+
Execution::Runner::Outcome.new(status: :error, runtime: unchanged.runtime,
|
|
175
|
+
message: "tests also #{how} with the method unchanged#{hint}")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The method saboteur would redefine must be the one Ruby is running.
|
|
179
|
+
def load_problem(subject)
|
|
180
|
+
owner = @reevaluator.resolve_owner(subject)
|
|
181
|
+
target = subject.singleton ? owner.singleton_class : owner
|
|
182
|
+
method = own_method(target, subject.name)
|
|
183
|
+
return "method not loaded" unless method
|
|
184
|
+
|
|
185
|
+
file, line = method.source_location
|
|
186
|
+
return nil if file && Selection::CoverageMap.canonical(file) == subject.file && line == subject.start_line
|
|
187
|
+
|
|
188
|
+
"shadowed definition (runtime method at #{file}:#{line})"
|
|
189
|
+
rescue Error => e
|
|
190
|
+
"owner not loaded: #{e.message}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def own_method(target, name)
|
|
194
|
+
return nil unless target.method_defined?(name) || target.private_method_defined?(name)
|
|
195
|
+
|
|
196
|
+
method = target.instance_method(name)
|
|
197
|
+
method = method.super_method while method && method.owner != target
|
|
198
|
+
method
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def note_leftovers(subject, what, outcome)
|
|
202
|
+
return if outcome.leftover_pids.empty?
|
|
203
|
+
|
|
204
|
+
@diagnostics.warn("#{subject.id} #{what}: killed #{outcome.leftover_pids.size} process(es) it left running")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def skip_all(candidates, subject, reason, covered_by: [])
|
|
208
|
+
candidates.map do |mutation, text|
|
|
209
|
+
MutantResult.new(subject: subject, mutation: mutation, status: :skipped, mutated_source: text,
|
|
210
|
+
reason: reason, covered_by: covered_by)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# The subjects to mutate: every def in the included files, narrowed by
|
|
7
|
+
# --subject patterns and by --since (methods with a changed line).
|
|
8
|
+
class SubjectSelection
|
|
9
|
+
# Lines changed since the --since ref, by file; nil without --since.
|
|
10
|
+
attr_reader :changed_lines
|
|
11
|
+
|
|
12
|
+
def initialize(config, diagnostics)
|
|
13
|
+
@config = config
|
|
14
|
+
@diagnostics = diagnostics
|
|
15
|
+
@root = File.realpath(config.root)
|
|
16
|
+
@changed_lines = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def select(files)
|
|
20
|
+
subjects = find(files)
|
|
21
|
+
subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
|
|
22
|
+
subjects = by_change(subjects, @config["since"]) if @config["since"]
|
|
23
|
+
subjects
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# "Klass#meth", "Klass.meth", or a namespace prefix ("Klass", "A::B").
|
|
27
|
+
def self.match?(id, pattern)
|
|
28
|
+
id == pattern || %w[# . ::].any? { |separator| id.start_with?("#{pattern}#{separator}") }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def find(files)
|
|
34
|
+
finder = SubjectFinder.new
|
|
35
|
+
files.flat_map do |file|
|
|
36
|
+
parsed = finder.call(file)
|
|
37
|
+
parsed.errors.first(1).each do |message|
|
|
38
|
+
@diagnostics.warn("#{file.delete_prefix("#{@root}/")} does not parse: #{message}")
|
|
39
|
+
end
|
|
40
|
+
parsed.subjects
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def by_pattern(subjects, patterns)
|
|
45
|
+
patterns.each do |pattern|
|
|
46
|
+
next if subjects.any? { |subject| self.class.match?(subject.id, pattern) }
|
|
47
|
+
|
|
48
|
+
@diagnostics.warn("--subject #{pattern} matches no subject (patterns are names like " \
|
|
49
|
+
"Klass, Klass#meth or Klass.meth, not globs)")
|
|
50
|
+
end
|
|
51
|
+
subjects.select { |subject| patterns.any? { |pattern| self.class.match?(subject.id, pattern) } }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def by_change(subjects, ref)
|
|
55
|
+
@changed_lines = Selection::GitDiff.changed_lines(ref, root: @root)
|
|
56
|
+
.transform_keys { |file| Selection::CoverageMap.canonical(file) }
|
|
57
|
+
subjects.select do |subject|
|
|
58
|
+
@changed_lines[subject.file]&.any? { |line| subject.contains_line?(line) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|