saboteur 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +153 -1
- data/README.md +526 -73
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +305 -30
- data/lib/saboteur/config.rb +65 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +88 -0
- data/lib/saboteur/engine/baseline_guard.rb +116 -0
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +40 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +344 -0
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +125 -25
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +210 -0
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +69 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +14 -3
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +131 -0
- data/lib/saboteur/reporter/html.rb +213 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +21 -4
- data/lib/saboteur/reporter/terminal.rb +76 -2
- data/lib/saboteur/result.rb +61 -9
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +7 -0
- metadata +30 -2
|
@@ -11,7 +11,7 @@ module Saboteur
|
|
|
11
11
|
# unchanged, otherwise built by running the whole suite once in a
|
|
12
12
|
# supervised child and saved.
|
|
13
13
|
class CoverageStore
|
|
14
|
-
CACHE_INPUTS = %w[integration require rspec_args rails].freeze
|
|
14
|
+
CACHE_INPUTS = %w[integration require rspec_args rails tests].freeze
|
|
15
15
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
16
16
|
LOG = "coverage-build.log"
|
|
17
17
|
|
|
@@ -27,9 +27,9 @@ module Saboteur
|
|
|
27
27
|
def fetch(files)
|
|
28
28
|
path = File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
|
|
29
29
|
cached = @config["cache"] ? Selection::CoverageMap.load(path) : nil
|
|
30
|
-
return cached if cached
|
|
30
|
+
return refuse_empty_map(cached, files) if cached
|
|
31
31
|
|
|
32
|
-
map = build(files)
|
|
32
|
+
map = refuse_empty_map(build(files), files)
|
|
33
33
|
map.save(path)
|
|
34
34
|
map
|
|
35
35
|
end
|
|
@@ -52,8 +52,15 @@ module Saboteur
|
|
|
52
52
|
"Run through the saboteur executable and disable SimpleCov for the run."
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
@
|
|
56
|
-
|
|
55
|
+
scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
|
|
56
|
+
@diagnostics.inform("building coverage map (runs #{scope} once)")
|
|
57
|
+
# The suite child forked next inherits this heap; pack it first, to
|
|
58
|
+
# reduce later copying of shared pages.
|
|
59
|
+
Execution::ForkReadiness.prepare_heap
|
|
60
|
+
heartbeat = @diagnostics.progress_heartbeat("building coverage map")
|
|
61
|
+
result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
|
|
62
|
+
record(writer, files)
|
|
63
|
+
end
|
|
57
64
|
raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
|
|
58
65
|
raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
|
|
59
66
|
|
|
@@ -63,6 +70,29 @@ module Saboteur
|
|
|
63
70
|
Selection::CoverageMap.from_json(result.payload)
|
|
64
71
|
end
|
|
65
72
|
|
|
73
|
+
# A whole-suite build that records no line in any included file fails
|
|
74
|
+
# the run (exit 3): usually the setup hid the app from Coverage
|
|
75
|
+
# (Bootsnap's compile cache is the default Rails case); it can also
|
|
76
|
+
# mean no test in the suite exercises any included file. Either way a
|
|
77
|
+
# run on this map would test nothing, and exit 0 would lie. With
|
|
78
|
+
# explicit tests (--test) an empty map is a legitimate outcome
|
|
79
|
+
# instead: the user narrowed the suite to specs that may not touch
|
|
80
|
+
# these sources, so the map passes with a warning. Applies to a cached
|
|
81
|
+
# map too, so a map cached before this check cannot keep a run green,
|
|
82
|
+
# and the guard runs before saving, so an empty map is never cached.
|
|
83
|
+
def refuse_empty_map(map, files)
|
|
84
|
+
return map if files.empty? || map.lines.any? { |_file, by_line| by_line.any? }
|
|
85
|
+
|
|
86
|
+
if @config["tests"].empty?
|
|
87
|
+
raise Error, "Coverage recorded no lines in #{files.size} included files during a whole-suite " \
|
|
88
|
+
"build. Is Bootsnap's compile cache on? If not, no test may exercise these files. " \
|
|
89
|
+
"Run `saboteur doctor`."
|
|
90
|
+
end
|
|
91
|
+
@diagnostics.warn("Coverage recorded no lines in #{files.size} included files; " \
|
|
92
|
+
"the selected specs may not exercise them.")
|
|
93
|
+
map
|
|
94
|
+
end
|
|
95
|
+
|
|
66
96
|
def died(status)
|
|
67
97
|
how = if status&.signaled? then "was killed by signal #{Signal.signame(status.termsig)}"
|
|
68
98
|
else "exited with status #{status&.exitstatus}"
|
|
@@ -86,7 +116,11 @@ module Saboteur
|
|
|
86
116
|
recorder = Selection::Recorder.new(files)
|
|
87
117
|
recorder.start
|
|
88
118
|
Kernel.srand(@config["seed"])
|
|
89
|
-
@integration.run_all(recorder)
|
|
119
|
+
passed = @integration.run_all(recorder)
|
|
120
|
+
if passed == false
|
|
121
|
+
raise Error, "the unchanged suite failed while building coverage. Fix the suite, then run again " \
|
|
122
|
+
"with seed #{@config['seed']}."
|
|
123
|
+
end
|
|
90
124
|
build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
91
125
|
writer.write(recorder.result(build_seconds: build_seconds).to_json)
|
|
92
126
|
writer.close
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Non-Ruby files a test run can read and that change its outcome: Rails
|
|
7
|
+
# fixtures, schema dumps and database/environment configuration, plus
|
|
8
|
+
# anything listed under `fingerprint_extra`. Ruby files are tracked
|
|
9
|
+
# elsewhere; these have no .rb extension, so the Ruby walk misses them.
|
|
10
|
+
# An absent pattern simply matches nothing, so plain gems are unaffected.
|
|
11
|
+
module DataInputs
|
|
12
|
+
GLOBS = %w[
|
|
13
|
+
spec/fixtures/**/*
|
|
14
|
+
spec/**/fixtures/**/*
|
|
15
|
+
test/fixtures/**/*
|
|
16
|
+
db/structure.sql
|
|
17
|
+
db/schema.rb
|
|
18
|
+
config/database.yml
|
|
19
|
+
config/database.yml.erb
|
|
20
|
+
config/*.yml
|
|
21
|
+
config/environments/*.yml
|
|
22
|
+
config/credentials/*.yml.enc
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# True when the absolute path is one of the declared inputs, whether or
|
|
26
|
+
# not the file exists yet. A run must never write there.
|
|
27
|
+
def self.match?(root, path, extra: [])
|
|
28
|
+
return false unless path.start_with?("#{root}/")
|
|
29
|
+
|
|
30
|
+
relative = path.delete_prefix("#{root}/")
|
|
31
|
+
flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
32
|
+
(GLOBS + extra).any? { |pattern| File.fnmatch?(pattern, relative, flags) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Sorted absolute paths of regular files (symlinks followed) under root.
|
|
36
|
+
def self.files(root, extra: [])
|
|
37
|
+
(GLOBS + extra).flat_map { |pattern| Dir.glob(pattern, File::FNM_DOTMATCH, base: root) }
|
|
38
|
+
.map { |relative| File.join(root, relative) }
|
|
39
|
+
.select { |path| File.file?(path) }.uniq.sort
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -6,6 +6,26 @@ module Saboteur
|
|
|
6
6
|
# What a run has to say besides results: warnings for the report, the
|
|
7
7
|
# count of mutations dropped for not parsing, and progress marks.
|
|
8
8
|
class Diagnostics
|
|
9
|
+
# See progress_heartbeat. Time-gated itself, so the supervisor can
|
|
10
|
+
# call tick on every poll.
|
|
11
|
+
class ProgressHeartbeat
|
|
12
|
+
def initialize(diagnostics, label, interval: 30)
|
|
13
|
+
@diagnostics = diagnostics
|
|
14
|
+
@label = label
|
|
15
|
+
@interval = interval
|
|
16
|
+
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
@last_print = @started
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tick
|
|
21
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
return if now - @last_print < @interval
|
|
23
|
+
|
|
24
|
+
@last_print = now
|
|
25
|
+
@diagnostics.inform("#{@label}: #{(now - @started).round} s elapsed")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
9
29
|
MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
|
|
10
30
|
|
|
11
31
|
attr_reader :warnings, :invalid_mutations
|
|
@@ -28,6 +48,14 @@ module Saboteur
|
|
|
28
48
|
@err.puts("saboteur: #{message}")
|
|
29
49
|
end
|
|
30
50
|
|
|
51
|
+
# A heartbeat for a long phase: the supervisor's wait loop calls tick
|
|
52
|
+
# in the parent after the fork, so no extra thread is ever alive at
|
|
53
|
+
# fork time. Prints "<label>: N s elapsed" about every interval
|
|
54
|
+
# seconds, so a long phase does not look stuck.
|
|
55
|
+
def progress_heartbeat(label, interval: 30)
|
|
56
|
+
ProgressHeartbeat.new(self, label, interval: interval)
|
|
57
|
+
end
|
|
58
|
+
|
|
31
59
|
def progress(result)
|
|
32
60
|
@err.print(MARKS.fetch(result.status, "?"))
|
|
33
61
|
@err.flush
|
|
@@ -20,6 +20,14 @@ module Saboteur
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# The changed lines a mutant touches, sorted. A statement that spans
|
|
24
|
+
# several lines is mutated as one piece and reported at its first line,
|
|
25
|
+
# which can sit above the line that changed; this names the changed line.
|
|
26
|
+
def changed_lines_for(subject, mutation)
|
|
27
|
+
changed = @changed_lines.fetch(subject.file, Set.new)
|
|
28
|
+
mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
private
|
|
24
32
|
|
|
25
33
|
def edited_lines(subject, edit)
|
|
@@ -42,7 +42,8 @@ module Saboteur
|
|
|
42
42
|
"position" => list.index { |mutation, _| mutation.equal?(result.mutation) },
|
|
43
43
|
"status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
|
|
44
44
|
"killed_by" => result.killed_by, "covered_by" => result.covered_by,
|
|
45
|
-
"kill_cause" => result.kill_cause, "reused" => result.reused
|
|
45
|
+
"kill_cause" => result.kill_cause, "reused" => result.reused?,
|
|
46
|
+
"rejected_kill" => result.rejected_kill?
|
|
46
47
|
}
|
|
47
48
|
end
|
|
48
49
|
|
|
@@ -55,7 +56,8 @@ module Saboteur
|
|
|
55
56
|
result = MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
|
|
56
57
|
mutated_source: text, reason: data["reason"], runtime: data["runtime"],
|
|
57
58
|
killed_by: data["killed_by"], covered_by: data["covered_by"],
|
|
58
|
-
kill_cause: data["kill_cause"], reused: data["reused"]
|
|
59
|
+
kill_cause: data["kill_cause"], reused: data["reused"],
|
|
60
|
+
rejected_kill: data.fetch("rejected_kill", false))
|
|
59
61
|
@cache&.record(result) unless result.reused?
|
|
60
62
|
result
|
|
61
63
|
end
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "data_inputs"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
class Engine
|
|
10
|
+
# Read-only identity for declared project inputs, not a Ruby dependency graph.
|
|
11
|
+
# Capture before boot; verify before reuse/save. Unknown inputs are unsafe.
|
|
12
|
+
class RunFingerprint
|
|
13
|
+
FORMAT_VERSION = 1
|
|
14
|
+
MANIFESTS = %w[Gemfile gems.rb Gemfile.lock gems.locked .rspec .rspec-local].freeze
|
|
15
|
+
LOCATION_SETTINGS = %w[cache_dir json_report html_report].freeze
|
|
16
|
+
ENTRY_KINDS = %w[ruby manifest boot hook source_plugin extra data].freeze
|
|
17
|
+
EXCLUDED_DIRECTORIES = %w[.git vendor node_modules].freeze
|
|
18
|
+
Entry = Data.define(:path, :kind, :state, :sha256, :required)
|
|
19
|
+
Change = Data.define(:path, :reason)
|
|
20
|
+
Snapshot = Data.define(:format_version, :digest, :inventory, :settings_digest, :runtime_digest, :complete,
|
|
21
|
+
:issues) do
|
|
22
|
+
def to_h
|
|
23
|
+
super.merge(inventory: inventory.map(&:to_h))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
Verification = Data.define(:unchanged, :complete, :current_digest, :changes, :issues)
|
|
27
|
+
|
|
28
|
+
def self.restore_snapshot(hash)
|
|
29
|
+
fields = canonical_mapping(hash).transform_keys(&:to_sym)
|
|
30
|
+
validate_snapshot_fields(fields)
|
|
31
|
+
inventory = fields.fetch(:inventory).map { |entry| restore_entry(entry) }.freeze
|
|
32
|
+
raise ArgumentError, "duplicate inventory paths" unless inventory.map(&:path).uniq.size == inventory.size
|
|
33
|
+
|
|
34
|
+
issues = restore_issues(fields.fetch(:issues))
|
|
35
|
+
validate_snapshot_identity(fields, inventory, issues)
|
|
36
|
+
Snapshot.new(**fields, inventory: inventory, issues: issues)
|
|
37
|
+
rescue KeyError, TypeError, NoMethodError => e
|
|
38
|
+
raise ArgumentError, "invalid fingerprint snapshot: #{e.class}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.restore_issues(issues)
|
|
42
|
+
raise ArgumentError, "invalid issues" unless
|
|
43
|
+
issues.is_a?(Array) && issues.all?(String) && issues == issues.uniq.sort
|
|
44
|
+
|
|
45
|
+
issues.map { |issue| issue.dup.freeze }.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.validate_snapshot_identity(fields, inventory, issues)
|
|
49
|
+
raise ArgumentError, "invalid complete snapshot" if
|
|
50
|
+
fields[:complete] && (issues.any? || inventory.any? do |entry|
|
|
51
|
+
entry.state == "unreadable" || (entry.required && entry.state == "missing")
|
|
52
|
+
end)
|
|
53
|
+
raise ArgumentError, "unsorted inventory" unless inventory.map(&:path) == inventory.map(&:path).sort
|
|
54
|
+
|
|
55
|
+
inputs = { format: FORMAT_VERSION, files: inventory.map(&:to_h),
|
|
56
|
+
settings: fields[:settings_digest], runtime: fields[:runtime_digest] }
|
|
57
|
+
canonical = canonical_value(inputs)
|
|
58
|
+
raise ArgumentError, "fingerprint identity mismatch" unless
|
|
59
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical)) == fields[:digest]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.validate_snapshot_fields(fields)
|
|
63
|
+
raise ArgumentError, "invalid snapshot fields" unless fields.keys.sort == Snapshot.members.sort
|
|
64
|
+
raise ArgumentError, "unsupported fingerprint format" unless fields[:format_version] == FORMAT_VERSION
|
|
65
|
+
raise ArgumentError, "invalid completeness" unless [true, false].include?(fields[:complete])
|
|
66
|
+
|
|
67
|
+
%i[digest settings_digest runtime_digest].each do |key|
|
|
68
|
+
raise ArgumentError, "invalid fingerprint digest" unless fields.fetch(key).match?(/\A[0-9a-f]{64}\z/)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.restore_entry(entry)
|
|
73
|
+
values = canonical_mapping(entry).transform_keys(&:to_sym)
|
|
74
|
+
raise ArgumentError, "invalid fingerprint entry" unless valid_entry?(values)
|
|
75
|
+
|
|
76
|
+
Entry.new(**values)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.valid_entry?(values)
|
|
80
|
+
values.keys.sort == %i[kind path required sha256 state] && valid_path?(values[:path]) &&
|
|
81
|
+
[true,
|
|
82
|
+
false].include?(values[:required]) && ENTRY_KINDS.include?(values[:kind]) &&
|
|
83
|
+
(!%w[ruby boot hook
|
|
84
|
+
source_plugin].include?(values[:kind]) || values[:required]) && valid_state?(values[:state],
|
|
85
|
+
values[:sha256])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.valid_path?(path)
|
|
89
|
+
unless path.is_a?(String) && !path.empty? && !path.include?("\0") &&
|
|
90
|
+
!path.include?("\\") && !path.end_with?("/")
|
|
91
|
+
return false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
candidate = path.delete_prefix("external:")
|
|
95
|
+
absolute = path.start_with?("external:")
|
|
96
|
+
return false unless candidate.start_with?("/") == absolute
|
|
97
|
+
|
|
98
|
+
segments = candidate.split("/")
|
|
99
|
+
segments.shift if absolute
|
|
100
|
+
segments.none? { |part| part.empty? || %w[. ..].include?(part) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.valid_state?(state, digest)
|
|
104
|
+
return digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) if state == "present"
|
|
105
|
+
|
|
106
|
+
%w[missing unreadable].include?(state) && digest.nil?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.runtime_identity
|
|
110
|
+
{ engine: RUBY_ENGINE, version: RUBY_VERSION, platform: RUBY_PLATFORM,
|
|
111
|
+
patchlevel: RUBY_PATCHLEVEL, saboteur: VERSION }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def initialize(config:, root: config.root, extra_files: [], runtime_identity: self.class.runtime_identity)
|
|
115
|
+
@config = config
|
|
116
|
+
@root = File.realpath(root)
|
|
117
|
+
raise ArgumentError, "root differs from config.root" unless @root == File.realpath(config.root)
|
|
118
|
+
|
|
119
|
+
@extra_files = extra_files.map do |file|
|
|
120
|
+
options = file.is_a?(Hash) ? file : { path: file, required: true }
|
|
121
|
+
[File.expand_path(options.fetch(:path), @root), options.fetch(:required, true)]
|
|
122
|
+
end.freeze
|
|
123
|
+
@runtime_identity = canonical(runtime_identity)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def snapshot
|
|
127
|
+
issues = output_overlap_issues
|
|
128
|
+
entries = inventory(issues).sort_by(&:path).freeze
|
|
129
|
+
issues = issues.uniq.sort
|
|
130
|
+
settings_digest = hash_value(@config.to_h.except(*LOCATION_SETTINGS))
|
|
131
|
+
runtime_digest = hash_value(@runtime_identity)
|
|
132
|
+
digest = hash_value(format: FORMAT_VERSION, files: entries.map(&:to_h),
|
|
133
|
+
settings: settings_digest, runtime: runtime_digest)
|
|
134
|
+
Snapshot.new(format_version: FORMAT_VERSION, digest: digest, inventory: entries,
|
|
135
|
+
settings_digest: settings_digest, runtime_digest: runtime_digest,
|
|
136
|
+
complete: issues.empty?, issues: issues.freeze)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def verify(previous)
|
|
140
|
+
current = snapshot
|
|
141
|
+
changes = changes_between(previous, current)
|
|
142
|
+
complete = previous.complete && current.complete && previous.format_version == FORMAT_VERSION
|
|
143
|
+
Verification.new(unchanged: complete && previous.digest == current.digest,
|
|
144
|
+
complete: complete, current_digest: current.digest,
|
|
145
|
+
changes: changes.freeze, issues: current.issues)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.canonical_value(value)
|
|
149
|
+
case value
|
|
150
|
+
when Hash then canonical_mapping(value)
|
|
151
|
+
when Array then value.map { |entry| canonical_value(entry) }.freeze
|
|
152
|
+
when String then value.dup.freeze
|
|
153
|
+
when NilClass, TrueClass, FalseClass, Integer then value
|
|
154
|
+
when Float
|
|
155
|
+
raise ArgumentError, "non-finite identity number" unless value.finite?
|
|
156
|
+
|
|
157
|
+
value
|
|
158
|
+
else raise ArgumentError, "unsupported identity value"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.canonical_mapping(value)
|
|
163
|
+
raise ArgumentError, "invalid identity key" unless value.keys.all? do |key|
|
|
164
|
+
key.is_a?(String) || key.is_a?(Symbol)
|
|
165
|
+
end
|
|
166
|
+
raise ArgumentError, "duplicate normalized identity key" unless value.keys.map(&:to_s).uniq.size == value.size
|
|
167
|
+
|
|
168
|
+
value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonical_value(value[key])] }.freeze
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def canonical_path(path)
|
|
174
|
+
expanded = File.expand_path(path, @root)
|
|
175
|
+
return File.realpath(expanded) if File.exist?(expanded)
|
|
176
|
+
|
|
177
|
+
parent = File.dirname(expanded)
|
|
178
|
+
File.join(canonical_path(parent), File.basename(expanded))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def output_overlap_issues
|
|
182
|
+
paths = required_files.map(&:first) + MANIFESTS.map { |name| File.join(@root, name) } +
|
|
183
|
+
DataInputs.files(@root, extra: @config["fingerprint_extra"])
|
|
184
|
+
outputs = %w[json_report html_report].filter_map { |key| @config.fetch(key) }
|
|
185
|
+
.map { |path| canonical_path(path) }
|
|
186
|
+
issues = outputs.filter_map do |path|
|
|
187
|
+
if path.end_with?(".rb") || paths.include?(path) || File.directory?(path) || data_input?(path)
|
|
188
|
+
"output overlaps input: #{display_path(path)}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
issues.concat(cache_overlap_issues(paths))
|
|
192
|
+
issues
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def cache_overlap_issues(paths)
|
|
196
|
+
cache = canonical_path(@config["cache_dir"])
|
|
197
|
+
protected = [@root] + %w[app lib spec test/fixtures].map { |name| File.join(@root, name) } + paths
|
|
198
|
+
inside = protected.any? do |path|
|
|
199
|
+
path == cache || path.start_with?("#{cache}/") || (path != @root && cache.start_with?("#{path}/"))
|
|
200
|
+
end
|
|
201
|
+
overlaps = inside || data_input?(cache)
|
|
202
|
+
overlaps ? ["cache directory overlaps inputs: #{display_path(cache)}"] : []
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Declared fixture, schema or config input, or a path inside a fixture tree.
|
|
206
|
+
def data_input?(path)
|
|
207
|
+
DataInputs.match?(@root, path, extra: @config["fingerprint_extra"])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def inventory(issues)
|
|
211
|
+
paths = project_files(issues).to_h { |path| [path, "ruby"] }
|
|
212
|
+
MANIFESTS.each { |name| paths[File.join(@root, name)] = "manifest" }
|
|
213
|
+
data_files(issues).each { |path| paths[path] ||= "data" }
|
|
214
|
+
required = merged_required_files
|
|
215
|
+
required.each { |path, (kind, _)| paths[path] = kind }
|
|
216
|
+
paths.map do |path, kind|
|
|
217
|
+
needed = required.key?(path) ? required[path].last : !%w[manifest data].include?(kind)
|
|
218
|
+
read_entry(path, kind, needed, issues)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Fixtures, schema dumps and database config. A pattern with no match is
|
|
223
|
+
# fine; a listed file that vanishes later shows up as "removed". Like Ruby
|
|
224
|
+
# files, data reached through a symlink (the file or a directory above it)
|
|
225
|
+
# is an undeclared boundary: its real path could be an output or lie outside
|
|
226
|
+
# the project, so the snapshot is incomplete instead of guessing.
|
|
227
|
+
def data_files(issues)
|
|
228
|
+
DataInputs.files(@root, extra: @config["fingerprint_extra"]).reject do |path|
|
|
229
|
+
next false unless symlinked?(path)
|
|
230
|
+
|
|
231
|
+
issues << "symlink input: #{display_path(path)}".freeze
|
|
232
|
+
true
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def symlinked?(path)
|
|
237
|
+
current = @root
|
|
238
|
+
path.delete_prefix("#{@root}/").split("/").any? { |part| File.symlink?(current = File.join(current, part)) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def merged_required_files
|
|
242
|
+
required_files.group_by(&:first).transform_values do |entries|
|
|
243
|
+
roles = entries.map { |_, kind, _| kind }
|
|
244
|
+
role = (roles - ["extra"]).min || "extra"
|
|
245
|
+
[role, entries.any? { |_, _, needed| needed }]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Walk without following symlink directories. A project symlink is an
|
|
250
|
+
# undeclared dependency boundary, not a silently omitted source tree.
|
|
251
|
+
def project_files(issues, directory = @root)
|
|
252
|
+
Dir.children(directory).sort.flat_map do |name|
|
|
253
|
+
path = File.join(directory, name)
|
|
254
|
+
next [] if excluded?(path, name)
|
|
255
|
+
|
|
256
|
+
if File.symlink?(path)
|
|
257
|
+
issues << "symlink input: #{display_path(path)}".freeze
|
|
258
|
+
next []
|
|
259
|
+
end
|
|
260
|
+
next project_files(issues, path) if File.directory?(path)
|
|
261
|
+
|
|
262
|
+
File.file?(path) && path.end_with?(".rb") ? [path] : []
|
|
263
|
+
end
|
|
264
|
+
rescue SystemCallError
|
|
265
|
+
issues << "unreadable directory: #{display_path(directory)}".freeze
|
|
266
|
+
[]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def excluded?(path, name)
|
|
270
|
+
EXCLUDED_DIRECTORIES.include?(name) || path == canonical_path(@config["cache_dir"]) ||
|
|
271
|
+
%w[json_report html_report].filter_map { |key| @config.fetch(key) }.any? do |output|
|
|
272
|
+
path == canonical_path(output)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def required_files
|
|
277
|
+
paths = %w[require hooks].flat_map do |key|
|
|
278
|
+
@config[key].map { |file| [resolve(file), key == "hooks" ? "hook" : "boot", true] }
|
|
279
|
+
end
|
|
280
|
+
paths += @config["sources"].reject { |entry| entry == "builtin" }.map do |entry|
|
|
281
|
+
[resolve(entry.partition(":").first), "source_plugin", true]
|
|
282
|
+
end
|
|
283
|
+
paths + @extra_files.map { |path, needed| [path, "extra", needed] }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def resolve(file)
|
|
287
|
+
path = File.expand_path(file, @root)
|
|
288
|
+
return path if File.exist?(path) || File.extname(path) == ".rb"
|
|
289
|
+
return "#{path}.rb" if File.exist?("#{path}.rb")
|
|
290
|
+
|
|
291
|
+
path
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def read_entry(path, kind, required, issues)
|
|
295
|
+
name = display_path(path).freeze
|
|
296
|
+
unless File.exist?(path)
|
|
297
|
+
issues << "missing required input: #{name}".freeze if required
|
|
298
|
+
return Entry.new(path: name, kind: kind.freeze, state: "missing", sha256: nil, required: required)
|
|
299
|
+
end
|
|
300
|
+
Entry.new(path: name, kind: kind.freeze, state: "present", sha256: Digest::SHA256.file(path).hexdigest.freeze,
|
|
301
|
+
required: required)
|
|
302
|
+
rescue SystemCallError, IOError
|
|
303
|
+
issues << "unreadable input: #{name}".freeze
|
|
304
|
+
Entry.new(path: name, kind: kind.freeze, state: "unreadable", sha256: nil, required: required)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def display_path(path)
|
|
308
|
+
path.start_with?("#{@root}/") ? path.delete_prefix("#{@root}/") : "external:#{path}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def changes_between(previous, current)
|
|
312
|
+
before = previous.inventory.to_h { |entry| [entry.path, entry] }
|
|
313
|
+
after = current.inventory.to_h { |entry| [entry.path, entry] }
|
|
314
|
+
changes = (before.keys | after.keys).sort.filter_map do |path|
|
|
315
|
+
next if before[path] == after[path]
|
|
316
|
+
|
|
317
|
+
Change.new(path: path.freeze, reason: change_reason(before[path], after[path]))
|
|
318
|
+
end
|
|
319
|
+
if previous.settings_digest != current.settings_digest
|
|
320
|
+
changes << Change.new(path: "settings",
|
|
321
|
+
reason: "changed")
|
|
322
|
+
end
|
|
323
|
+
changes << Change.new(path: "runtime", reason: "changed") if previous.runtime_digest != current.runtime_digest
|
|
324
|
+
changes
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def change_reason(before, after)
|
|
328
|
+
return "added" unless before
|
|
329
|
+
return "removed" unless after
|
|
330
|
+
return "unreadable" if after.state == "unreadable"
|
|
331
|
+
|
|
332
|
+
"changed"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def canonical(value)
|
|
336
|
+
self.class.canonical_value(value)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def hash_value(value)
|
|
340
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical(value))).freeze
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
@@ -7,11 +7,10 @@ module Saboteur
|
|
|
7
7
|
# unit of work parallel workers will share out.
|
|
8
8
|
#
|
|
9
9
|
# Order of business: skip what cannot be run (refinements, not loaded,
|
|
10
|
-
# shadowed, no covering test); coarse mutants; fine mutants
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# tests' recorded durations.
|
|
10
|
+
# shadowed, no covering test); coarse mutants; all eligible fine mutants;
|
|
11
|
+
# then check every kill and timeout against the unchanged method
|
|
12
|
+
# (VerdictCheck). Each mutant runs only the tests that executed its edit,
|
|
13
|
+
# likeliest killers first, with a timeout from their recorded durations.
|
|
15
14
|
class Scheduler
|
|
16
15
|
# A subject to run, with what its mutants need. killers counts the
|
|
17
16
|
# tests that killed earlier mutants of this subject; slowdown is how
|
|
@@ -45,7 +44,7 @@ module Saboteur
|
|
|
45
44
|
reused = all_reused(plan, candidates)
|
|
46
45
|
return reused if reused
|
|
47
46
|
|
|
48
|
-
results = run_candidates(
|
|
47
|
+
results = run_candidates(candidates) do |mutation, text|
|
|
49
48
|
reuse(plan, mutation, text) || execute(plan, mutation, text)
|
|
50
49
|
end
|
|
51
50
|
checked(subject, results)
|
|
@@ -86,21 +85,13 @@ module Saboteur
|
|
|
86
85
|
return nil unless @cache
|
|
87
86
|
|
|
88
87
|
catch(:must_run) do
|
|
89
|
-
run_candidates(
|
|
88
|
+
run_candidates(candidates) { |mutation, text| reuse(plan, mutation, text) || throw(:must_run) }
|
|
90
89
|
end
|
|
91
90
|
end
|
|
92
91
|
|
|
93
|
-
def run_candidates(
|
|
92
|
+
def run_candidates(candidates, &run_one)
|
|
94
93
|
coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
|
|
95
94
|
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
95
|
results + fine.map { |mutation, text| run_one.call(mutation, text) }
|
|
105
96
|
end
|
|
106
97
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "shellwords"
|
|
6
|
+
require_relative "data_inputs"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
8
9
|
class Engine
|
|
@@ -22,7 +23,7 @@ module Saboteur
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def files
|
|
25
|
-
(shared_spec_files + boot_and_hook_files + option_files + required_by_options).uniq.sort
|
|
26
|
+
(shared_spec_files + boot_and_hook_files + option_files + required_by_options + data_files).uniq.sort
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def digest
|
|
@@ -33,6 +34,12 @@ module Saboteur
|
|
|
33
34
|
|
|
34
35
|
private
|
|
35
36
|
|
|
37
|
+
# Fixtures, schema dumps and database config: not Ruby, but they change
|
|
38
|
+
# what the tests see, so a stale cache must not survive an edit to them.
|
|
39
|
+
def data_files
|
|
40
|
+
DataInputs.files(@root, extra: @config["fingerprint_extra"])
|
|
41
|
+
end
|
|
42
|
+
|
|
36
43
|
def shared_spec_files
|
|
37
44
|
Dir.glob("spec/**/*.rb", base: @root).reject { |file| file.end_with?("_spec.rb") }
|
|
38
45
|
.map { |file| File.join(@root, file) }
|
|
@@ -63,12 +63,14 @@ module Saboteur
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def checked(result, failing_runs, failing)
|
|
66
|
+
return result if result.reused?
|
|
67
|
+
|
|
66
68
|
if named_kill?(result) && failing_runs.key?(sequence(result))
|
|
67
69
|
why = failing_runs[sequence(result)]
|
|
68
70
|
result.as_error("killing test #{result.killed_by.first} also fails with the method unchanged" \
|
|
69
|
-
"#{": #{why}" if why}")
|
|
71
|
+
"#{": #{why}" if why}", rejected_kill: true)
|
|
70
72
|
elsif failing.include?(result)
|
|
71
|
-
result.as_error("tests also fail with the method unchanged")
|
|
73
|
+
result.as_error("tests also fail with the method unchanged", rejected_kill: true)
|
|
72
74
|
else
|
|
73
75
|
result
|
|
74
76
|
end
|