saboteur 0.4.0 → 0.5.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 +167 -0
- data/README.md +393 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +101 -121
- data/lib/saboteur/config.rb +76 -6
- data/lib/saboteur/doctor.rb +40 -8
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +6 -4
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +192 -18
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +31 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/reporter/agent_commands.rb +7 -1
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +11 -5
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +15 -2
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +131 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +41 -6
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +43 -2
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require_relative "manifest"
|
|
9
|
+
require_relative "evidence"
|
|
10
|
+
|
|
11
|
+
module Saboteur
|
|
12
|
+
module Baseline
|
|
13
|
+
# Local evidence is separate from the editable proposal. This is not
|
|
14
|
+
# attestation against the account owner changing both records.
|
|
15
|
+
class Acceptance
|
|
16
|
+
MAX_BYTES = 20 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
def initialize(directory:, protected_paths:, verify:, require_evidence: false)
|
|
19
|
+
@directory = artifact_path(directory)
|
|
20
|
+
@protected = protected_paths.map { |path| Identity.canonical_path(path) }
|
|
21
|
+
@verify = verify
|
|
22
|
+
@require_evidence = require_evidence
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def propose(document:, proposal:, destination:, previous: nil, evidence: nil)
|
|
26
|
+
proposal, destination = paths(proposal, destination)
|
|
27
|
+
verify_previous(destination, previous)
|
|
28
|
+
validate_execution(document, evidence)
|
|
29
|
+
validate_document(document)
|
|
30
|
+
verify_inputs(document)
|
|
31
|
+
prepare_directory
|
|
32
|
+
token = SecureRandom.hex(16)
|
|
33
|
+
record = { "token" => token, "destination" => destination, "previous" => previous, "document" => document }
|
|
34
|
+
record["execution"] = evidence if evidence
|
|
35
|
+
path = evidence_path(token)
|
|
36
|
+
exclusive_write(path, record)
|
|
37
|
+
exclusive_write(proposal, record)
|
|
38
|
+
record
|
|
39
|
+
rescue SystemCallError => e
|
|
40
|
+
raise ConfigError, "baseline proposal: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def accept(proposal:, destination:, previous: nil)
|
|
44
|
+
proposal, destination = paths(proposal, destination)
|
|
45
|
+
edited = read_record(proposal)
|
|
46
|
+
prepare_directory
|
|
47
|
+
original = read_record(evidence_path(edited.fetch("token")), private_record: true)
|
|
48
|
+
validate_binding(original, edited, destination)
|
|
49
|
+
validate_execution(edited.fetch("document"), original["execution"])
|
|
50
|
+
Manifest.new(edited.fetch("document"))
|
|
51
|
+
verify_inputs(original.fetch("document"))
|
|
52
|
+
publish(destination, edited.fetch("document"), original.fetch("previous"), previous)
|
|
53
|
+
File.unlink(evidence_path(edited.fetch("token")))
|
|
54
|
+
destination
|
|
55
|
+
rescue SystemCallError, KeyError => e
|
|
56
|
+
raise ConfigError, "baseline evidence: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def paths(proposal, destination)
|
|
62
|
+
list = [proposal, destination].map { |path| artifact_path(path) }
|
|
63
|
+
aliases = list.first == list.last || File.identical?(*list)
|
|
64
|
+
raise ConfigError, "baseline proposal and destination alias" if aliases
|
|
65
|
+
|
|
66
|
+
if list.any? { |path| path == @directory || path.start_with?("#{@directory}/") }
|
|
67
|
+
raise ConfigError, "baseline artifact aliases local evidence directory"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
(list + [@directory]).each { |path| safe_path(path) }
|
|
71
|
+
list
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def artifact_path(path)
|
|
75
|
+
expanded = File.expand_path(path)
|
|
76
|
+
File.join(Identity.canonical_path(File.dirname(expanded)), File.basename(expanded))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def safe_path(path)
|
|
80
|
+
current = path
|
|
81
|
+
loop do
|
|
82
|
+
raise ConfigError, "baseline symlink path: #{current}" if File.symlink?(current)
|
|
83
|
+
break if current == File.dirname(current)
|
|
84
|
+
|
|
85
|
+
current = File.dirname(current)
|
|
86
|
+
end
|
|
87
|
+
if @protected.any? do |input|
|
|
88
|
+
input == path || File.identical?(input, path)
|
|
89
|
+
end
|
|
90
|
+
raise ConfigError, "baseline artifact aliases an input: #{path}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def prepare_directory
|
|
95
|
+
FileUtils.mkdir_p(@directory, mode: 0o700)
|
|
96
|
+
return if File.stat(@directory).mode.nobits?(0o077) && File.stat(@directory).uid == Process.uid
|
|
97
|
+
|
|
98
|
+
raise ConfigError,
|
|
99
|
+
"baseline evidence directory is not private"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def evidence_path(token)
|
|
103
|
+
raise ConfigError, "baseline evidence token" unless token.is_a?(String) && token.match?(/\A[0-9a-f]{32}\z/)
|
|
104
|
+
|
|
105
|
+
File.join(@directory, "#{token}.json")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_record(path, private_record: false)
|
|
109
|
+
safe_path(path)
|
|
110
|
+
if private_record && (!File.stat(path).mode.nobits?(0o077) || File.stat(path).uid != Process.uid)
|
|
111
|
+
raise ConfigError, "baseline evidence record is not private"
|
|
112
|
+
end
|
|
113
|
+
raise ConfigError, "baseline evidence too large" if File.size(path) > MAX_BYTES
|
|
114
|
+
|
|
115
|
+
record = File.open(path, File::RDONLY | File::NOFOLLOW) { |file| JSON.parse(file.read) }
|
|
116
|
+
unless record.is_a?(Hash) && [%w[destination document previous token],
|
|
117
|
+
%w[destination document execution previous token]].include?(record.keys.sort)
|
|
118
|
+
raise ConfigError, "baseline evidence fields"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
validate_nested_document(record.fetch("document"))
|
|
122
|
+
record
|
|
123
|
+
rescue JSON::ParserError => e
|
|
124
|
+
raise ConfigError, "baseline evidence JSON: #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_nested_document(document)
|
|
128
|
+
return if document.is_a?(Hash) && document["accepted"].is_a?(Array) && document["accepted"].all?(Hash)
|
|
129
|
+
|
|
130
|
+
raise ConfigError, "baseline proposal document or acceptance shape"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validate_execution(document, execution)
|
|
134
|
+
validate_nested_document(document)
|
|
135
|
+
raise ConfigError, "baseline execution evidence missing" if @require_evidence && !execution
|
|
136
|
+
return unless execution
|
|
137
|
+
|
|
138
|
+
captured = Evidence.new(execution).manifest(reasons: "validation")
|
|
139
|
+
proposed = JSON.parse(JSON.generate(document))
|
|
140
|
+
proposed.fetch("accepted").each { |entry| entry["reason"] = "validation" }
|
|
141
|
+
raise ConfigError, "baseline execution evidence differs" unless captured == proposed
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_document(document)
|
|
145
|
+
copy = JSON.parse(JSON.generate(document))
|
|
146
|
+
copy.fetch("accepted").each { |entry| entry["reason"] = "pending review" }
|
|
147
|
+
Manifest.new(copy)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def verify_inputs(document)
|
|
151
|
+
raise ConfigError, "baseline inputs changed or unverified" unless @verify.call(document.fetch("metadata"))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validate_binding(original, edited, destination)
|
|
155
|
+
raise ConfigError, "baseline destination differs from proposal" unless original["destination"] == destination
|
|
156
|
+
|
|
157
|
+
before = JSON.parse(JSON.generate(original))
|
|
158
|
+
after = JSON.parse(JSON.generate(edited))
|
|
159
|
+
[before, after].each do |record|
|
|
160
|
+
record.fetch("document").fetch("accepted").each do |entry|
|
|
161
|
+
entry.delete("reason")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
raise ConfigError, "baseline proposal evidence changed" unless before == after
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def verify_previous(destination, previous)
|
|
168
|
+
if previous.nil?
|
|
169
|
+
raise ConfigError, "baseline destination exists" if File.exist?(destination)
|
|
170
|
+
elsif !File.file?(destination) || Digest::SHA256.file(destination).hexdigest != previous
|
|
171
|
+
raise ConfigError, "baseline previous destination changed"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def exclusive_write(path, document)
|
|
176
|
+
safe_path(path)
|
|
177
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
178
|
+
file.write(JSON.generate(document))
|
|
179
|
+
file.flush
|
|
180
|
+
file.fsync
|
|
181
|
+
end
|
|
182
|
+
rescue Errno::EEXIST
|
|
183
|
+
raise ConfigError, "baseline output exists: #{path}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def publish(destination, document, recorded, reviewed)
|
|
187
|
+
safe_path("#{destination}.lock")
|
|
188
|
+
File.open("#{destination}.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
189
|
+
lock.flock(File::LOCK_EX)
|
|
190
|
+
safe_path(destination)
|
|
191
|
+
raise ConfigError, "baseline replacement requires matching reviewed hash" unless recorded == reviewed
|
|
192
|
+
|
|
193
|
+
verify_previous(destination, recorded)
|
|
194
|
+
verify_inputs(document)
|
|
195
|
+
replace(destination, document, initial: recorded.nil?)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def replace(destination, document, initial: false)
|
|
200
|
+
Tempfile.create([".baseline-", ".json"], File.dirname(destination)) do |file|
|
|
201
|
+
file.chmod(0o600)
|
|
202
|
+
file.write(JSON.generate(document))
|
|
203
|
+
file.flush
|
|
204
|
+
file.fsync
|
|
205
|
+
if initial
|
|
206
|
+
begin
|
|
207
|
+
File.link(file.path, destination)
|
|
208
|
+
rescue Errno::EEXIST
|
|
209
|
+
raise ConfigError, "baseline destination exists"
|
|
210
|
+
end
|
|
211
|
+
else
|
|
212
|
+
File.rename(file.path, destination)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "workflow"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Baseline
|
|
8
|
+
module Command
|
|
9
|
+
def baseline_parser
|
|
10
|
+
OptionParser.new do |opts|
|
|
11
|
+
opts.banner = "Usage: saboteur baseline check|propose|accept --baseline PATH [--proposal PATH] " \
|
|
12
|
+
"[--epsilon POINTS]"
|
|
13
|
+
opts.on("--baseline PATH", "Accepted baseline destination") { |value| @baseline_path = value }
|
|
14
|
+
opts.on("--proposal PATH", "Editable proposal path (propose/accept)") { |value| @proposal_path = value }
|
|
15
|
+
opts.on("--previous SHA256", "Reviewed current destination hash for replacement") do |value|
|
|
16
|
+
@previous = value
|
|
17
|
+
end
|
|
18
|
+
opts.on("--epsilon POINTS", Float, "check: also fail when the score drops more than POINTS " \
|
|
19
|
+
"percentage points below the baseline's") { |value| @epsilon = value }
|
|
20
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
|
|
21
|
+
opts.on("--subject EXPR", "Whole subject filter; repeatable") { |value| add_subject(value) }
|
|
22
|
+
opts.on("--seed N", Integer, "Repeatable run seed") { |value| set(:seed, value) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def baseline
|
|
29
|
+
return show_help("baseline") if help_requested?
|
|
30
|
+
|
|
31
|
+
action = @argv.shift
|
|
32
|
+
parse_baseline(action)
|
|
33
|
+
payload = stdout_on_stderr do
|
|
34
|
+
workflow = Baseline::Workflow.new(config: config, err: @err)
|
|
35
|
+
baseline_action(workflow, action)
|
|
36
|
+
end
|
|
37
|
+
@out.puts(JSON.generate(payload))
|
|
38
|
+
payload.fetch("exit_code", CLI::EXIT_OK)
|
|
39
|
+
rescue ConfigError, SystemCallError => e
|
|
40
|
+
@err.puts("saboteur: #{e.message}")
|
|
41
|
+
CLI::EXIT_USAGE
|
|
42
|
+
rescue Error => e
|
|
43
|
+
@err.puts("saboteur: #{e.message}")
|
|
44
|
+
CLI::EXIT_TOOL
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_baseline(action)
|
|
48
|
+
raise CLI::UsageError, "baseline expects check, propose or accept" unless %w[check propose
|
|
49
|
+
accept].include?(action)
|
|
50
|
+
|
|
51
|
+
baseline_parser.parse!(@argv)
|
|
52
|
+
raise CLI::UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
53
|
+
raise CLI::UsageError, "--baseline PATH is required" unless @baseline_path
|
|
54
|
+
raise CLI::UsageError, "--proposal PATH is required" if action != "check" && !@proposal_path
|
|
55
|
+
if action == "check" && (@proposal_path || @previous)
|
|
56
|
+
raise CLI::UsageError,
|
|
57
|
+
"check does not use --proposal or --previous"
|
|
58
|
+
end
|
|
59
|
+
validate_epsilon(action)
|
|
60
|
+
raise CLI::UsageError, "--previous needs a SHA-256" if @previous && !@previous.match?(/\A[0-9a-f]{64}\z/)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_epsilon(action)
|
|
64
|
+
return unless @epsilon
|
|
65
|
+
|
|
66
|
+
raise CLI::UsageError, "--epsilon applies to check only" if action != "check"
|
|
67
|
+
raise CLI::UsageError, "--epsilon must be between 0 and 100" unless (0.0..100.0).cover?(@epsilon)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def baseline_action(workflow, action)
|
|
71
|
+
case action
|
|
72
|
+
when "check" then workflow.check(path: @baseline_path, epsilon: @epsilon)
|
|
73
|
+
when "propose" then workflow.propose(proposal: @proposal_path, destination: @baseline_path,
|
|
74
|
+
previous: @previous)
|
|
75
|
+
when "accept" then workflow.accept(proposal: @proposal_path, destination: @baseline_path, previous: @previous)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Baseline
|
|
6
|
+
# Classifies evidence without suppressing a mutant or changing its score.
|
|
7
|
+
class Comparison
|
|
8
|
+
CATEGORY_NAMES = %w[accepted new changed resolved_kills detected_timeouts unmatched errors].freeze
|
|
9
|
+
EXITS = { "clean" => 0, "review_required" => 1, "incompatible" => 2, "incomplete" => 3 }.freeze
|
|
10
|
+
|
|
11
|
+
SCORED_STATUSES = %w[killed timeout survived].freeze
|
|
12
|
+
DETECTED_STATUSES = %w[killed timeout].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :state, :categories, :issues, :score
|
|
15
|
+
|
|
16
|
+
# epsilon is the largest score drop, in percentage points, that still
|
|
17
|
+
# passes. nil turns the score gate off.
|
|
18
|
+
def initialize(baseline:, current:, epsilon: nil)
|
|
19
|
+
@issues = current.fetch("issues").dup
|
|
20
|
+
@categories = {}
|
|
21
|
+
unless baseline.fetch("compatibility") == current.fetch("compatibility")
|
|
22
|
+
@state = "incompatible"
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@categories = classify(baseline, current)
|
|
27
|
+
@score = score_change(baseline, current, epsilon) if epsilon
|
|
28
|
+
@state = outcome(current)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def exit_code
|
|
32
|
+
EXITS.fetch(state)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def classify(baseline, current)
|
|
38
|
+
categories = CATEGORY_NAMES.to_h { |name| [name, []] }
|
|
39
|
+
previous = baseline.fetch("accepted").to_h { |entry| [entry.fetch("id"), entry] }
|
|
40
|
+
current.fetch("candidates").each do |entry|
|
|
41
|
+
accepted = previous.delete(entry.fetch("id"))
|
|
42
|
+
name = category(entry, accepted)
|
|
43
|
+
categories.fetch(name) << entry if name
|
|
44
|
+
end
|
|
45
|
+
categories["unmatched"] = previous.values
|
|
46
|
+
categories.each_value { |entries| entries.sort_by! { |entry| order(entry) } }
|
|
47
|
+
categories
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def category(entry, accepted)
|
|
51
|
+
case entry.fetch("status")
|
|
52
|
+
when "survived"
|
|
53
|
+
return "new" unless accepted
|
|
54
|
+
return "accepted" if entry["context"] == accepted.fetch("context")
|
|
55
|
+
|
|
56
|
+
"changed"
|
|
57
|
+
when "killed" then accepted && "resolved_kills"
|
|
58
|
+
when "timeout" then accepted && "detected_timeouts"
|
|
59
|
+
else "errors"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def outcome(current)
|
|
64
|
+
return "incomplete" unless current.fetch("complete") && issues.empty? && !current.fetch("candidates").empty?
|
|
65
|
+
return "incomplete" unless categories.fetch("errors").empty?
|
|
66
|
+
return "review_required" if %w[new changed unmatched].any? { |name| !categories.fetch(name).empty? }
|
|
67
|
+
return "review_required" if score&.fetch("exceeded")
|
|
68
|
+
|
|
69
|
+
"clean"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Percent of scored mutants that were detected, as saboteur's own score
|
|
73
|
+
# counts them: killed and timed out over killed, timed out and survived.
|
|
74
|
+
# An exact Rational, so the drop is compared with epsilon without
|
|
75
|
+
# binary float error.
|
|
76
|
+
def detected_counts(candidates)
|
|
77
|
+
scored = candidates.select { |entry| SCORED_STATUSES.include?(entry.fetch("status")) }
|
|
78
|
+
return nil if scored.empty?
|
|
79
|
+
|
|
80
|
+
[scored.count { |entry| DETECTED_STATUSES.include?(entry.fetch("status")) }, scored.size]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def score_change(baseline, current, epsilon)
|
|
84
|
+
before = detected_counts(baseline.fetch("candidates"))
|
|
85
|
+
now = detected_counts(current.fetch("candidates"))
|
|
86
|
+
return nil unless before && now
|
|
87
|
+
|
|
88
|
+
accepted = Rational(before[0] * 100, before[1])
|
|
89
|
+
measured = Rational(now[0] * 100, now[1])
|
|
90
|
+
drop = accepted - measured
|
|
91
|
+
# The epsilon as the decimal the user typed, not its binary float. The
|
|
92
|
+
# counts let a reader recompute the verdict exactly.
|
|
93
|
+
{ "baseline" => accepted.to_f, "current" => measured.to_f, "drop" => drop.to_f, "epsilon" => epsilon,
|
|
94
|
+
"exceeded" => drop > Rational(epsilon.to_s),
|
|
95
|
+
"baseline_detected" => before[0], "baseline_scored" => before[1],
|
|
96
|
+
"current_detected" => now[0], "current_scored" => now[1] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def order(entry)
|
|
100
|
+
identity = entry.fetch("identity", [])
|
|
101
|
+
[identity[1].to_s, identity[2].to_s, entry.fetch("id")]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Baseline
|
|
6
|
+
# Exact planned/result reconciliation, independent of survivor acceptance.
|
|
7
|
+
class Completeness
|
|
8
|
+
EXECUTED = %w[killed survived timeout].freeze
|
|
9
|
+
attr_reader :issues
|
|
10
|
+
|
|
11
|
+
def initialize(planned:, results:, subjects:, stable_suite:, stable_inputs:, skipped_subjects:, omitted:,
|
|
12
|
+
generation_issues:)
|
|
13
|
+
@issues = []
|
|
14
|
+
issues << "unchanged_suite_incomplete" unless stable_suite
|
|
15
|
+
issues << "inputs_unverified" unless stable_inputs
|
|
16
|
+
validate_inventory(planned, subjects)
|
|
17
|
+
inventory(planned, results)
|
|
18
|
+
skipped_subjects.each { |entry| issues << "subject_skipped:#{entry}" }
|
|
19
|
+
omitted.each { |entry| issues << "omitted:#{entry}" }
|
|
20
|
+
generation_issues.each { |entry| issues << "generation:#{entry}" }
|
|
21
|
+
@issues = issues.uniq.sort.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def complete?
|
|
25
|
+
issues.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_inventory(planned, subjects)
|
|
31
|
+
issues << "empty_candidates" if planned.empty?
|
|
32
|
+
issues << "empty_subjects" if subjects.empty?
|
|
33
|
+
issues << "duplicate_subjects" unless subjects.uniq.size == subjects.size
|
|
34
|
+
issues << "duplicate_candidates" unless planned.uniq.size == planned.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inventory(planned, results)
|
|
38
|
+
observed = results.map { |entry| entry.fetch("id") }
|
|
39
|
+
issues << "duplicate_results" unless observed.uniq.size == observed.size
|
|
40
|
+
(planned - observed).each { |id| issues << "missing_result:#{id}" }
|
|
41
|
+
(observed - planned).each { |id| issues << "unexpected_result:#{id}" }
|
|
42
|
+
validate_results(results)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_results(results)
|
|
46
|
+
results.each do |entry|
|
|
47
|
+
status = entry.fetch("status")
|
|
48
|
+
issues << "unexecuted:#{entry.fetch('id')}:#{status}" unless EXECUTED.include?(status)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "manifest"
|
|
5
|
+
require_relative "run_evidence"
|
|
6
|
+
require_relative "../engine/run_fingerprint"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Baseline
|
|
10
|
+
# Validates the producer document before it can enter the acceptance path.
|
|
11
|
+
# A claimed complete flag alone is never evidence of complete execution.
|
|
12
|
+
class Evidence
|
|
13
|
+
FIELDS = %w[compatibility snapshot subjects candidates planned complete issues generation_events
|
|
14
|
+
omissions discovery_failures inline_suppressions].freeze
|
|
15
|
+
COMPATIBILITY = %w[generator runtime config scope].freeze
|
|
16
|
+
PLAN_FIELDS = %w[id identity context].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :document
|
|
19
|
+
|
|
20
|
+
def initialize(document)
|
|
21
|
+
@document = Engine::RunFingerprint.canonical_value(document)
|
|
22
|
+
fields!(@document, FIELDS)
|
|
23
|
+
fields!(@document.fetch("compatibility"), COMPATIBILITY)
|
|
24
|
+
@snapshot = Engine::RunFingerprint.restore_snapshot(@document.fetch("snapshot"))
|
|
25
|
+
raise ConfigError, "baseline incomplete fingerprint" unless @snapshot.complete
|
|
26
|
+
|
|
27
|
+
validate_compatibility
|
|
28
|
+
validate_generation
|
|
29
|
+
validate_plan
|
|
30
|
+
# Manifest owns exact candidate, identity, subject and acceptance shapes.
|
|
31
|
+
Manifest.new(manifest(reasons: "validation"))
|
|
32
|
+
rescue ArgumentError, KeyError, TypeError, NoMethodError => e
|
|
33
|
+
raise ConfigError, "baseline evidence: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def manifest(reasons: "")
|
|
37
|
+
metadata = document.fetch("compatibility").merge(document.slice("snapshot", "subjects", "complete", "issues"))
|
|
38
|
+
accepted = document.fetch("candidates").select { |entry| entry.fetch("status") == "survived" }.map do |entry|
|
|
39
|
+
entry.slice("id", "context").merge("reason" => reasons)
|
|
40
|
+
end
|
|
41
|
+
{ "format" => Manifest::FORMAT, "identity_version" => Identity::VERSION,
|
|
42
|
+
"metadata" => metadata, "candidates" => document.fetch("candidates"), "accepted" => accepted }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def array!(value, name)
|
|
48
|
+
raise ConfigError, "baseline #{name}" unless value.is_a?(Array)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fields!(value, fields)
|
|
52
|
+
raise ConfigError, "baseline evidence fields" unless value.keys.sort == fields.sort
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_compatibility
|
|
56
|
+
compatibility = document.fetch("compatibility")
|
|
57
|
+
config = compatibility.fetch("config")
|
|
58
|
+
runtime = compatibility.fetch("runtime")
|
|
59
|
+
fields!(config, Config::DEFAULTS.keys - RunEvidence::OUTPUT_SETTINGS)
|
|
60
|
+
fields!(runtime, %w[engine version platform patchlevel saboteur])
|
|
61
|
+
raise ConfigError, "baseline nonfresh config" if config["since"] || config["incremental"]
|
|
62
|
+
|
|
63
|
+
scope = config.slice("include", "exclude", "subjects")
|
|
64
|
+
raise ConfigError, "baseline scope mismatch" unless compatibility.fetch("scope") == scope
|
|
65
|
+
|
|
66
|
+
valid = digest(config) == @snapshot.settings_digest && digest(runtime) == @snapshot.runtime_digest
|
|
67
|
+
raise ConfigError, "baseline snapshot settings mismatch" unless valid
|
|
68
|
+
|
|
69
|
+
validate_generator(compatibility.fetch("generator"), config, runtime)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_generator(generator, config, runtime)
|
|
73
|
+
fields!(generator, %w[saboteur sources plugins])
|
|
74
|
+
plugins = document.fetch("snapshot").fetch("inventory").select { |entry| entry["kind"] == "source_plugin" }
|
|
75
|
+
raise ConfigError, "baseline generator mismatch" unless
|
|
76
|
+
generator["saboteur"] == runtime["saboteur"] && generator["sources"] == config["sources"] &&
|
|
77
|
+
generator["plugins"] == plugins
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def digest(value)
|
|
81
|
+
Digest::SHA256.hexdigest(JSON.generate(Engine::RunFingerprint.canonical_value(value)))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_generation
|
|
85
|
+
clear = document.values_at("omissions", "discovery_failures", "inline_suppressions").all? do |entries|
|
|
86
|
+
entries == []
|
|
87
|
+
end
|
|
88
|
+
raise ConfigError, "baseline omitted or undiscovered work (including inline suppressions)" unless clear
|
|
89
|
+
|
|
90
|
+
events = document.fetch("generation_events")
|
|
91
|
+
array!(events, "generation events")
|
|
92
|
+
|
|
93
|
+
events.each do |event|
|
|
94
|
+
fields!(event, %w[file subject source operator reason])
|
|
95
|
+
raise ConfigError, "baseline incomplete generation" unless RunEvidence::SAFE_DROPS.include?(event["reason"])
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_plan
|
|
100
|
+
plan = document.fetch("planned")
|
|
101
|
+
array!(plan, "candidate plan")
|
|
102
|
+
raise ConfigError, "baseline empty candidate plan" if plan.empty?
|
|
103
|
+
|
|
104
|
+
plan.each { |entry| fields!(entry, PLAN_FIELDS) }
|
|
105
|
+
results = document.fetch("candidates")
|
|
106
|
+
array!(results, "candidates")
|
|
107
|
+
|
|
108
|
+
expected = results.map { |entry| entry.slice(*PLAN_FIELDS) }
|
|
109
|
+
raise ConfigError, "baseline planned results differ" unless
|
|
110
|
+
plan.sort_by { |entry| entry.fetch("id") } == expected.sort_by { |entry| entry.fetch("id") }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Baseline
|
|
10
|
+
# Stable mutation identity, separate from the bytes accepted by a reviewer.
|
|
11
|
+
class Identity
|
|
12
|
+
VERSION = 1
|
|
13
|
+
|
|
14
|
+
attr_reader :tuple, :id, :context
|
|
15
|
+
|
|
16
|
+
def initialize(subject:, mutation:, root:)
|
|
17
|
+
@subject = subject
|
|
18
|
+
@mutation = mutation
|
|
19
|
+
raise ConfigError, "baseline mutation subject differs from its subject" unless mutation.subject_id == subject.id
|
|
20
|
+
|
|
21
|
+
file = Pathname.new(self.class.canonical_path(subject.file))
|
|
22
|
+
base = Pathname.new(self.class.canonical_path(root))
|
|
23
|
+
path = file.relative_path_from(base).to_s
|
|
24
|
+
raise ConfigError, "baseline subject outside project root" if path == ".." || path.start_with?("../")
|
|
25
|
+
|
|
26
|
+
@tuple = immutable([VERSION, path, subject.id, mutation.source, mutation.operator, mutation.coarse, edits])
|
|
27
|
+
@id = Digest::SHA256.hexdigest(JSON.generate(tuple)).freeze
|
|
28
|
+
@context = Digest::SHA256.hexdigest(subject.source.b).freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Resolve existing ancestors too, so a missing evidence path under a
|
|
32
|
+
# symlinked checkout has the same root identity as an existing subject.
|
|
33
|
+
def self.canonical_path(path)
|
|
34
|
+
expanded = File.expand_path(path)
|
|
35
|
+
return File.realpath(expanded) if File.exist?(expanded)
|
|
36
|
+
|
|
37
|
+
parent = File.dirname(expanded)
|
|
38
|
+
File.join(canonical_path(parent), File.basename(expanded))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def entry
|
|
42
|
+
{ "id" => id, "identity" => tuple, "context" => context }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def immutable(value)
|
|
48
|
+
case value
|
|
49
|
+
when Array then value.map { |entry| immutable(entry) }.freeze
|
|
50
|
+
when String then value.dup.freeze
|
|
51
|
+
else value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def edits
|
|
56
|
+
previous_end = nil
|
|
57
|
+
@mutation.edits.sort_by { |edit| [edit.start_byte, edit.end_byte] }.map do |edit|
|
|
58
|
+
validate_edit(edit, previous_end)
|
|
59
|
+
previous_end = edit.end_byte
|
|
60
|
+
original = @subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
|
|
61
|
+
[edit.start_byte - @subject.start_byte, edit.end_byte - @subject.start_byte,
|
|
62
|
+
original.b.unpack1("H*"), edit.replacement.b.unpack1("H*")]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_edit(edit, previous_end)
|
|
67
|
+
unless self.class.canonical_path(edit.file) == self.class.canonical_path(@subject.file) &&
|
|
68
|
+
edit.start_byte >= @subject.start_byte && edit.end_byte <= @subject.end_byte &&
|
|
69
|
+
edit.end_byte >= edit.start_byte
|
|
70
|
+
raise ConfigError, "baseline edit outside subject or in another file"
|
|
71
|
+
end
|
|
72
|
+
return unless previous_end && edit.start_byte < previous_end
|
|
73
|
+
|
|
74
|
+
raise ConfigError, "baseline edits overlap"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|