saboteur 0.3.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -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 +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- 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 +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -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 +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -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 +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- 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 +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- 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/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -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 +61 -2
- 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 +25 -5
- 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 +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- 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 +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
data/exe/saboteur
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Line coverage must be set up before any target code loads, so it happens
|
|
6
|
-
# first.
|
|
6
|
+
# first. For `run` it also counts from here: what executes while the app and
|
|
7
|
+
# spec files load is how ran-at-load mutants are told apart from uncovered
|
|
8
|
+
# ones. The engine suspends counting right after boot and reads the
|
|
9
|
+
# counters as the map's load lines (a forked child would not inherit them).
|
|
7
10
|
require "coverage"
|
|
8
|
-
Coverage.setup(lines: true) if Coverage.state == :idle
|
|
11
|
+
Coverage.setup(lines: true, methods: true, branches: true) if Coverage.state == :idle
|
|
12
|
+
Coverage.resume if ARGV.first == "run" && Coverage.state == :suspended
|
|
9
13
|
|
|
10
14
|
require "saboteur/cli"
|
|
11
15
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "rails/generators"
|
|
5
|
+
require "saboteur/install"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module Generators
|
|
9
|
+
# rails generate saboteur:install [--force]
|
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
|
11
|
+
desc "Writes config/saboteur.rb, bin/saboteur and a .gitignore entry for a Rails + RSpec app."
|
|
12
|
+
|
|
13
|
+
def install_saboteur
|
|
14
|
+
installer = Saboteur::Install.new(root: destination_root, force: options[:force])
|
|
15
|
+
installer.call.each { |result| say_status(result.status, result.path) }
|
|
16
|
+
hint = installer.gemfile_hint
|
|
17
|
+
say("\n#{hint}") if hint
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# The instructions for an AI coding agent, printed by `saboteur agent-skill`.
|
|
6
|
+
# Paste them into AGENTS.md or CLAUDE.md. docs/agent-skill.md carries the same
|
|
7
|
+
# block; a spec keeps the two equal.
|
|
8
|
+
module AgentSkill
|
|
9
|
+
TEXT = <<~MARKDOWN
|
|
10
|
+
## Mutation testing with saboteur
|
|
11
|
+
|
|
12
|
+
After you change covered Ruby behavior or its specs, and RSpec is green, run saboteur
|
|
13
|
+
on your diff before you say the work is done:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
saboteur run --agent --since origin/main
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Read the JSON on stdout. `status` is `complete`, `partial` or `not_applicable`.
|
|
20
|
+
The exit code is strict: 0 means complete and clean, 1 means a survivor needs
|
|
21
|
+
review, 2 means a bad command or config, 3 means the run was incomplete or failed.
|
|
22
|
+
|
|
23
|
+
To preview cost without booting the app or running tests, use `saboteur run --dry-run`.
|
|
24
|
+
`--list-operators` shows names and tiers; `--operators arithmetic,comparison` restricts the run.
|
|
25
|
+
|
|
26
|
+
When a mutant survives:
|
|
27
|
+
|
|
28
|
+
1. Pick one live survivor from `findings.shown`.
|
|
29
|
+
2. Add the smallest test that checks the observable behavior the mutant changed.
|
|
30
|
+
A finding's `testHint`, when present, says where to look (for a comparison, the
|
|
31
|
+
`case` of operands that separates the two). It is a suggestion; the survivor
|
|
32
|
+
can also be an equivalent mutant.
|
|
33
|
+
3. Replay it with the command in `next.replay` (it uses the recorded seed) and
|
|
34
|
+
confirm the mutant is now killed.
|
|
35
|
+
4. Run the diff again. Repeat until the exit code is 0.
|
|
36
|
+
|
|
37
|
+
Rules:
|
|
38
|
+
|
|
39
|
+
- Report `partial` and `not_applicable` results as they are. Say what had no
|
|
40
|
+
coverage or was capped; do not call an incomplete run clean.
|
|
41
|
+
- Never change production behavior only to kill a mutant.
|
|
42
|
+
- Never add survivors to the ignore list yourself. Ask the owner.
|
|
43
|
+
- Run `saboteur help` for every command, option and exit code.
|
|
44
|
+
MARKDOWN
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -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
|