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
|
@@ -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
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module Baseline
|
|
9
|
+
# Validated, immutable human acceptance and the complete run it describes.
|
|
10
|
+
class Manifest
|
|
11
|
+
FORMAT = 1
|
|
12
|
+
METADATA = %w[generator runtime config scope snapshot subjects complete issues].freeze
|
|
13
|
+
STATUSES = %w[killed survived timeout].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :candidates, :accepted
|
|
16
|
+
|
|
17
|
+
def initialize(document)
|
|
18
|
+
@document = JSON.parse(JSON.generate(document))
|
|
19
|
+
fields!(@document, %w[format identity_version metadata candidates accepted], "manifest")
|
|
20
|
+
fail!("format") unless @document["format"] == FORMAT
|
|
21
|
+
fail!("identity version") unless @document["identity_version"] == 1
|
|
22
|
+
validate_metadata
|
|
23
|
+
validate_results
|
|
24
|
+
freeze_tree(@document)
|
|
25
|
+
candidates.freeze
|
|
26
|
+
accepted.freeze
|
|
27
|
+
rescue JSON::GeneratorError, JSON::ParserError, TypeError, ArgumentError => e
|
|
28
|
+
raise ConfigError, "baseline JSON: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_results
|
|
32
|
+
@candidates = index(@document["candidates"], "candidates") { |entry| validate_candidate(entry) }
|
|
33
|
+
fail!("empty candidates") if candidates.empty?
|
|
34
|
+
@accepted = index(@document["accepted"], "accepted") { |entry| validate_acceptance(entry) }
|
|
35
|
+
survivors = candidates.values.select { |entry| entry["status"] == "survived" }.map { |entry| entry["id"] }
|
|
36
|
+
fail!("acceptance does not cover exactly the survivors") unless accepted.keys.sort == survivors.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
@document
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def compatibility
|
|
44
|
+
@document.fetch("metadata").slice("generator", "runtime", "config", "scope")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_metadata
|
|
50
|
+
metadata = @document["metadata"]
|
|
51
|
+
fields!(metadata, METADATA, "metadata")
|
|
52
|
+
fail!("incomplete evidence") unless metadata["complete"] == true && metadata["issues"] == []
|
|
53
|
+
%w[generator runtime config scope snapshot].each do |name|
|
|
54
|
+
fail!("metadata #{name}") unless metadata[name].is_a?(Hash) && !metadata[name].empty?
|
|
55
|
+
end
|
|
56
|
+
validate_subjects(metadata["subjects"])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_subjects(subjects)
|
|
60
|
+
fail!("subject inventory") unless subjects.is_a?(Array) && !subjects.empty?
|
|
61
|
+
subjects.each do |subject|
|
|
62
|
+
fail!("subject entry") unless subject.is_a?(Array) && subject.size == 2
|
|
63
|
+
path!(subject.first)
|
|
64
|
+
text!(subject.last, "subject")
|
|
65
|
+
end
|
|
66
|
+
fail!("duplicate subjects") unless subjects.uniq.size == subjects.size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def index(entries, name)
|
|
70
|
+
fail!(name) unless entries.is_a?(Array)
|
|
71
|
+
entries.each_with_object({}) do |entry, dictionary|
|
|
72
|
+
yield entry
|
|
73
|
+
id = entry["id"]
|
|
74
|
+
fail!("duplicate #{name}") if dictionary.key?(id)
|
|
75
|
+
dictionary[id] = entry
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_candidate(entry)
|
|
80
|
+
fields!(entry, %w[id identity context status kill_cause], "candidate")
|
|
81
|
+
tuple = entry["identity"]
|
|
82
|
+
fail!("identity tuple") unless tuple.is_a?(Array) && tuple.size == 7 && tuple.first == 1
|
|
83
|
+
path!(tuple[1])
|
|
84
|
+
text!(tuple[2], "identity text")
|
|
85
|
+
text!(tuple[3], "identity text")
|
|
86
|
+
text!(tuple[4], "identity text")
|
|
87
|
+
fail!("identity coarse") unless [true, false].include?(tuple[5])
|
|
88
|
+
validate_edits(tuple[6])
|
|
89
|
+
validate_verdict(entry, tuple)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_verdict(entry, tuple)
|
|
93
|
+
fail!("identity digest") unless entry["id"] == Digest::SHA256.hexdigest(JSON.generate(tuple))
|
|
94
|
+
digest!(entry["context"], "context")
|
|
95
|
+
fail!("status") unless STATUSES.include?(entry["status"])
|
|
96
|
+
cause = entry["kill_cause"]
|
|
97
|
+
text!(cause, "kill cause") unless cause.nil?
|
|
98
|
+
fail!("candidate subject missing") unless @document["metadata"]["subjects"].include?(tuple[1..2])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_edits(edits)
|
|
102
|
+
fail!("identity edits") unless edits.is_a?(Array) && !edits.empty?
|
|
103
|
+
previous = nil
|
|
104
|
+
edits.each do |edit|
|
|
105
|
+
fail!("identity edit") unless edit.is_a?(Array) && edit.size == 4
|
|
106
|
+
validate_edit(edit)
|
|
107
|
+
fail!("identity edit overlap/order") if previous && edit.first < previous
|
|
108
|
+
previous = edit[1]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_edit(edit)
|
|
113
|
+
first, last, original, replacement = edit
|
|
114
|
+
fail!("identity edit range") unless first.is_a?(Integer) && last.is_a?(Integer) && first >= 0 && last >= first
|
|
115
|
+
[original, replacement].each { |bytes| fail!("identity edit bytes") unless hex?(bytes) }
|
|
116
|
+
fail!("identity edit length") unless original.size == (last - first) * 2
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_acceptance(entry)
|
|
120
|
+
fields!(entry, %w[id context reason], "acceptance")
|
|
121
|
+
current = candidates[entry["id"]]
|
|
122
|
+
fail!("acceptance candidate") unless current && current["status"] == "survived"
|
|
123
|
+
fail!("acceptance context") unless current["context"] == entry["context"]
|
|
124
|
+
text!(entry["reason"], "reason")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fields!(value, fields, name)
|
|
128
|
+
fail!("#{name} fields") unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def path!(path)
|
|
132
|
+
text!(path, "path")
|
|
133
|
+
parts = path.split("/")
|
|
134
|
+
fail!("path") if parts.intersect?(["", ".", ".."]) || path.include?("\\") || path.end_with?("/")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def text!(value, name)
|
|
138
|
+
fail!(name) unless value.is_a?(String) && !value.strip.empty? && !value.include?("\0")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def digest!(value, name)
|
|
142
|
+
fail!(name) unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def hex?(value)
|
|
146
|
+
value.is_a?(String) && value.match?(/\A(?:[0-9a-f]{2})*\z/)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def freeze_tree(value)
|
|
150
|
+
case value
|
|
151
|
+
when Hash then value.each_value do |item|
|
|
152
|
+
freeze_tree(item)
|
|
153
|
+
end
|
|
154
|
+
when Array then value.each { |item| freeze_tree(item) }
|
|
155
|
+
end
|
|
156
|
+
value.freeze
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def fail!(what)
|
|
160
|
+
raise ConfigError, "baseline: invalid #{what}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "identity"
|
|
5
|
+
require_relative "completeness"
|
|
6
|
+
require_relative "../engine/run_fingerprint"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Baseline
|
|
10
|
+
# Fresh, pre-boot file identity and exact planned/result reconciliation.
|
|
11
|
+
# This does not attest external database, network or environment state.
|
|
12
|
+
class RunEvidence
|
|
13
|
+
SAFE_DROPS = %w[unchanged duplicate].freeze
|
|
14
|
+
OUTPUT_SETTINGS = %w[cache_dir json_report html_report score_history].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(config:, extra_files: [])
|
|
17
|
+
@config = config
|
|
18
|
+
@root = File.realpath(config.root)
|
|
19
|
+
@fingerprint = Engine::RunFingerprint.new(config: config, extra_files: extra_files)
|
|
20
|
+
@snapshot = @fingerprint.snapshot
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def plan(subjects, lists)
|
|
24
|
+
raise ConfigError, "baseline plan already recorded" if @planned
|
|
25
|
+
raise ConfigError, "baseline candidate lists differ from subjects" unless subjects.size == lists.size
|
|
26
|
+
|
|
27
|
+
@subjects = Engine::RunFingerprint.canonical_value(subjects.map do |subject|
|
|
28
|
+
[relative(subject.file), subject.id]
|
|
29
|
+
end)
|
|
30
|
+
@planned = Engine::RunFingerprint.canonical_value(subjects.zip(lists).flat_map do |subject, candidates|
|
|
31
|
+
candidates.map { |mutation, _| Identity.new(subject: subject, mutation: mutation, root: @root).entry }
|
|
32
|
+
end)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finish(results:, stable_suite:, skipped_subjects:, generation_events:, omissions:, discovery_failures: [],
|
|
36
|
+
inline_suppressions: [])
|
|
37
|
+
raise ConfigError, "baseline plan missing" unless @planned
|
|
38
|
+
|
|
39
|
+
observed = results.map { |result| outcome(result) }
|
|
40
|
+
generation = generation_events.map { |event| event.to_h.transform_keys(&:to_s) }
|
|
41
|
+
discovery = discovery_failures.map { |failure| failure.to_h.transform_keys(&:to_s) }
|
|
42
|
+
errors = generation_issues(generation, results) + context_issues(observed) + discovery_issues(discovery)
|
|
43
|
+
suppressions = suppression_scope(inline_suppressions)
|
|
44
|
+
errors += suppression_issues(suppressions)
|
|
45
|
+
completeness = assess(observed, stable_suite, skipped_subjects, omissions, errors)
|
|
46
|
+
document = { "compatibility" => compatibility, "snapshot" => @snapshot.to_h, "subjects" => @subjects,
|
|
47
|
+
"candidates" => observed, "planned" => @planned,
|
|
48
|
+
"complete" => completeness.complete?, "issues" => completeness.issues,
|
|
49
|
+
"generation_events" => generation, "omissions" => omissions.map(&:to_h),
|
|
50
|
+
"discovery_failures" => discovery, "inline_suppressions" => suppressions }
|
|
51
|
+
Engine::RunFingerprint.canonical_value(document)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def discovery_issues(entries)
|
|
57
|
+
entries.map { |entry| "discovery:#{entry.fetch('reason')}:#{relative(entry.fetch('file'))}" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def suppression_scope(entries)
|
|
61
|
+
entries.map { |entry| entry.transform_keys(&:to_s).merge("file" => relative(entry.fetch(:file))) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def suppression_issues(entries)
|
|
65
|
+
entries.map { |entry| "inline_suppressed:#{entry.fetch('subject')}:#{entry.fetch('digest')}" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def generation_issues(generation, results)
|
|
69
|
+
errors = generation.reject { |event| SAFE_DROPS.include?(event.fetch("reason")) }.map do |event|
|
|
70
|
+
[event.fetch("reason"), event.fetch("subject"), event["operator"]].compact.join(":")
|
|
71
|
+
end
|
|
72
|
+
errors + results.select(&:reused?).map { |result| "reused:#{result.subject.id}" }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def context_issues(observed)
|
|
76
|
+
planned = @planned.to_h { |entry| [entry.fetch("id"), entry.fetch("context")] }
|
|
77
|
+
observed.filter_map do |entry|
|
|
78
|
+
id = entry.fetch("id")
|
|
79
|
+
"changed_context:#{id}" if planned.key?(id) && planned.fetch(id) != entry.fetch("context")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def assess(observed, stable_suite, skipped_subjects, omissions, errors)
|
|
84
|
+
skipped = skipped_subjects.map { |subject, reason| "#{subject.id}:#{reason}" }
|
|
85
|
+
Completeness.new(planned: @planned.map { |entry| entry.fetch("id") }, results: observed,
|
|
86
|
+
subjects: @subjects, stable_suite: stable_suite,
|
|
87
|
+
stable_inputs: @fingerprint.verify(@snapshot).unchanged, skipped_subjects: skipped,
|
|
88
|
+
omitted: omissions.map { |entry| "#{entry.subject}:#{entry.count}" },
|
|
89
|
+
generation_issues: errors)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def compatibility
|
|
93
|
+
{ "generator" => { "saboteur" => VERSION, "sources" => @config["sources"],
|
|
94
|
+
"plugins" => @snapshot.inventory.select do |entry|
|
|
95
|
+
entry.kind == "source_plugin"
|
|
96
|
+
end.map(&:to_h) },
|
|
97
|
+
"runtime" => Engine::RunFingerprint.runtime_identity,
|
|
98
|
+
"config" => @config.to_h.except(*OUTPUT_SETTINGS),
|
|
99
|
+
"scope" => { "include" => @config["include"], "exclude" => @config["exclude"],
|
|
100
|
+
"subjects" => @config["subjects"] } }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def outcome(result)
|
|
104
|
+
Identity.new(subject: result.subject, mutation: result.mutation, root: @root).entry.merge(
|
|
105
|
+
"status" => result.status.to_s, "kill_cause" => result.kill_cause
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def relative(file)
|
|
110
|
+
path = Identity.canonical_path(file)
|
|
111
|
+
raise ConfigError, "baseline subject outside project root" unless path.start_with?("#{@root}/")
|
|
112
|
+
|
|
113
|
+
path.delete_prefix("#{@root}/")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "acceptance"
|
|
5
|
+
require_relative "comparison"
|
|
6
|
+
require_relative "../engine"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Baseline
|
|
10
|
+
# Opt-in orchestration: every check/proposal runs fresh; acceptance only
|
|
11
|
+
# edits reasons after verifying private execution evidence and current inputs.
|
|
12
|
+
class Workflow
|
|
13
|
+
def initialize(config:, err: $stderr)
|
|
14
|
+
@config = config
|
|
15
|
+
@err = err
|
|
16
|
+
@root = File.realpath(config.root)
|
|
17
|
+
raise ConfigError, "baseline requires fresh whole or subject scope" if config["since"] || config["incremental"]
|
|
18
|
+
raise ConfigError, "baseline cannot use a score threshold" if config["threshold"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check(path:, epsilon: nil)
|
|
22
|
+
baseline = load_manifest(path)
|
|
23
|
+
report = Engine.new(@config, err: @err, capture_evidence: true, evidence_files: [path]).call
|
|
24
|
+
unless Digest::SHA256.file(path).hexdigest == @baseline_digest
|
|
25
|
+
raise ConfigError,
|
|
26
|
+
"baseline changed during check"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
current = report.run_evidence
|
|
30
|
+
Evidence.new(current) if current["complete"] == true
|
|
31
|
+
# An incomplete producer report is useful for diagnosis, never acceptance.
|
|
32
|
+
comparison = Comparison.new(baseline: { "compatibility" => baseline.compatibility,
|
|
33
|
+
"accepted" => baseline.accepted.values,
|
|
34
|
+
"candidates" => baseline.candidates.values },
|
|
35
|
+
current: current, epsilon: epsilon)
|
|
36
|
+
payload = { "state" => comparison.state, "exit_code" => comparison.exit_code,
|
|
37
|
+
"categories" => comparison.categories, "issues" => comparison.issues }
|
|
38
|
+
payload["score"] = comparison.score if comparison.score
|
|
39
|
+
payload
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def propose(proposal:, destination:, previous: nil)
|
|
43
|
+
guard_artifacts(proposal, destination)
|
|
44
|
+
report = Engine.new(@config, err: @err, capture_evidence: true).call
|
|
45
|
+
unless report.run_evidence["complete"] == true
|
|
46
|
+
raise Error,
|
|
47
|
+
"baseline proposal requires complete execution evidence"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
captured = Evidence.new(report.run_evidence)
|
|
51
|
+
store(captured.document.fetch("snapshot")).propose(document: captured.manifest,
|
|
52
|
+
evidence: captured.document, proposal: proposal,
|
|
53
|
+
destination: destination, previous: previous)
|
|
54
|
+
{ "state" => "proposal", "proposal" => File.expand_path(proposal),
|
|
55
|
+
"destination" => File.expand_path(destination),
|
|
56
|
+
"survivors" => captured.manifest.fetch("accepted").size }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def accept(proposal:, destination:, previous: nil)
|
|
60
|
+
guard_artifacts(proposal, destination)
|
|
61
|
+
# The store reads and binds the private record; verifier uses its recorded snapshot.
|
|
62
|
+
accepted = store(nil).accept(proposal: proposal, destination: destination, previous: previous)
|
|
63
|
+
{ "state" => "accepted", "destination" => accepted }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def load_manifest(path)
|
|
69
|
+
raise ConfigError, "baseline file too large" if File.size(path) > Acceptance::MAX_BYTES
|
|
70
|
+
|
|
71
|
+
bytes = File.read(path)
|
|
72
|
+
@baseline_digest = Digest::SHA256.hexdigest(bytes)
|
|
73
|
+
manifest = Manifest.new(JSON.parse(bytes))
|
|
74
|
+
metadata = manifest.to_h.fetch("metadata")
|
|
75
|
+
candidates = manifest.candidates.values
|
|
76
|
+
restored = metadata.slice("snapshot", "subjects", "complete", "issues").merge(
|
|
77
|
+
"compatibility" => manifest.compatibility, "candidates" => candidates,
|
|
78
|
+
"planned" => candidates.map { |entry| entry.slice(*Evidence::PLAN_FIELDS) },
|
|
79
|
+
"generation_events" => [], "omissions" => [], "discovery_failures" => [], "inline_suppressions" => []
|
|
80
|
+
)
|
|
81
|
+
Evidence.new(restored)
|
|
82
|
+
manifest
|
|
83
|
+
rescue SystemCallError, JSON::ParserError, ArgumentError => e
|
|
84
|
+
raise ConfigError, "baseline file: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def store(snapshot)
|
|
88
|
+
captured = snapshot && Engine::RunFingerprint.restore_snapshot(snapshot)
|
|
89
|
+
protected = input_paths(captured || Engine::RunFingerprint.new(config: @config).snapshot)
|
|
90
|
+
verifier = lambda do |metadata|
|
|
91
|
+
saved = Engine::RunFingerprint.restore_snapshot(metadata.fetch("snapshot"))
|
|
92
|
+
extras = saved.inventory.select { |entry| entry.kind == "extra" }.map do |entry|
|
|
93
|
+
{ path: input_path(entry.path), required: entry.required }
|
|
94
|
+
end
|
|
95
|
+
Engine::RunFingerprint.new(config: @config, extra_files: extras).verify(saved).unchanged
|
|
96
|
+
end
|
|
97
|
+
Acceptance.new(directory: File.join(Identity.canonical_path(@config.path("cache_dir")), "baseline-evidence"),
|
|
98
|
+
protected_paths: protected, verify: verifier, require_evidence: true)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def input_paths(snapshot)
|
|
102
|
+
snapshot.inventory.map { |entry| input_path(entry.path) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def input_path(path)
|
|
106
|
+
path.start_with?("external:") ? path.delete_prefix("external:") : File.join(@root, path)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def guard_artifacts(*paths)
|
|
110
|
+
paths.each do |path|
|
|
111
|
+
expanded = Identity.canonical_path(path)
|
|
112
|
+
next unless expanded.end_with?(".rb") || Engine::DataInputs.match?(@root, expanded,
|
|
113
|
+
extra: @config["fingerprint_extra"]) ||
|
|
114
|
+
Engine::RunFingerprint::MANIFESTS.any? { |name| expanded == File.join(@root, name) }
|
|
115
|
+
|
|
116
|
+
raise ConfigError, "baseline artifact overlaps project input: #{path}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -67,7 +67,7 @@ module Saboteur
|
|
|
67
67
|
def read_result(result, number)
|
|
68
68
|
raise Error, "baseline run #{number} stopped: #{result.detail}" if result.stopped_by
|
|
69
69
|
raise Error, "baseline run #{number} exited without a result; check suite boot and exits" if result.payload.empty?
|
|
70
|
-
if result.
|
|
70
|
+
if result.child_failed?
|
|
71
71
|
raise Error, "baseline run #{number} exited unsuccessfully; fix suite cleanup and exit handlers"
|
|
72
72
|
end
|
|
73
73
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# How a finished run maps to an exit code and a recorded exit cause. Mixed
|
|
7
|
+
# into the CLI, which supplies `config` and the `@err` stream.
|
|
8
|
+
module ExitStatus
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def exit_code(report)
|
|
12
|
+
return EXIT_TOOL if mostly_errored?(report)
|
|
13
|
+
|
|
14
|
+
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
15
|
+
|
|
16
|
+
failed_quality_gate?(report) || gates.failed?(report, @err) ? EXIT_SURVIVORS : EXIT_OK
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def gates = (@gates ||= Gates.new(config))
|
|
20
|
+
|
|
21
|
+
def mostly_errored?(report)
|
|
22
|
+
return false unless report.majority_errored?
|
|
23
|
+
|
|
24
|
+
executed = report.executed
|
|
25
|
+
errors = executed.count { |result| result.status == :error }
|
|
26
|
+
@err.puts("saboteur: #{errors} of #{executed.size} mutants errored, so the run tested almost nothing; " \
|
|
27
|
+
"fix the errors and run again")
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Agent mode: the recorded cause is the payload's own verdict, so the
|
|
32
|
+
# JSON and the process exit classify the run the same way. An
|
|
33
|
+
# incomplete run that mostly errored keeps its distinct cause, so the
|
|
34
|
+
# harness rule means the same thing in both modes.
|
|
35
|
+
def agent_exit_cause(report, payload)
|
|
36
|
+
case payload[:verdict]
|
|
37
|
+
when "clean" then "ok"
|
|
38
|
+
when "survivors" then report.early_stop ? "failFast" : "survivors"
|
|
39
|
+
else report.majority_errored? ? "errorMajority" : "incomplete"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Why the run exits the way it does, recorded in the JSON report so an
|
|
44
|
+
# outer harness can tell "most mutants errored" from the other exit 3s.
|
|
45
|
+
def exit_cause(report, code)
|
|
46
|
+
case code
|
|
47
|
+
when EXIT_OK then "ok"
|
|
48
|
+
when EXIT_SURVIVORS then survivors_cause(report)
|
|
49
|
+
else report.majority_errored? ? "errorMajority" : "incompleteUnderThreshold"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A run stopped by --fail-fast always exits 1: it stopped on a survivor, and
|
|
54
|
+
# its score covers only the subjects that ran, so no threshold can pass it.
|
|
55
|
+
def survivors_cause(report)
|
|
56
|
+
return "failFast" if report.early_stop
|
|
57
|
+
|
|
58
|
+
gates.cause || (config["threshold"].nil? ? "survivors" : "belowThreshold")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# With --threshold the score decides; without it any unignored survivor does.
|
|
62
|
+
def failed_quality_gate?(report)
|
|
63
|
+
return true if report.early_stop
|
|
64
|
+
|
|
65
|
+
threshold = config["threshold"]
|
|
66
|
+
return report.unignored_survivors.any? if threshold.nil?
|
|
67
|
+
|
|
68
|
+
score = report.score
|
|
69
|
+
return missing_score_reported? if score.nil?
|
|
70
|
+
|
|
71
|
+
score * 100 < threshold
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A gate that cannot measure a score must not pass: CI would go green
|
|
75
|
+
# on a run that tested nothing.
|
|
76
|
+
def missing_score_reported?
|
|
77
|
+
@err.puts("saboteur: no mutant was tested, so there is no score to compare with --threshold")
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Errors and resource limits are outside the score. With a threshold they
|
|
82
|
+
# make the run incomplete, so the score alone cannot pass it.
|
|
83
|
+
def incomplete_under_threshold?(report)
|
|
84
|
+
return false if config["threshold"].nil?
|
|
85
|
+
|
|
86
|
+
count = report.counts.values_at(*(gates.judges_errors? ? %i[resource_limit] : %i[error resource_limit])).sum
|
|
87
|
+
@err.puts("saboteur: #{count} mutant(s) errored or hit a resource limit, so the score is incomplete") if
|
|
88
|
+
count.positive?
|
|
89
|
+
count.positive?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../reporter/score_history"
|
|
5
|
+
require_relative "../reporter/history_paths"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
class CLI
|
|
9
|
+
# Finished-run reporting shared by text and agent modes.
|
|
10
|
+
module RunReporting
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def text_run(report)
|
|
14
|
+
@err.puts
|
|
15
|
+
Reporter::Terminal.new(@out, verbose: @verbose || false, color: @color || :auto).call(report)
|
|
16
|
+
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
17
|
+
code = exit_code(report)
|
|
18
|
+
# The JSON carries the exit cause an outer harness trusts, so it is
|
|
19
|
+
# the completion evidence for the whole run: write it only after
|
|
20
|
+
# every other fallible reporting stage, or a late failure would
|
|
21
|
+
# leave a fresh, misleading report behind.
|
|
22
|
+
validate_history
|
|
23
|
+
html = write_html(report)
|
|
24
|
+
record_history(report)
|
|
25
|
+
validate_history
|
|
26
|
+
json = write_json(report, exit_cause(report, code))
|
|
27
|
+
announce_reports(json => "JSON", html => "HTML")
|
|
28
|
+
code
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def agent_run(arguments)
|
|
32
|
+
tree = WorkingTree.new(config.root, ignored: config.output_paths).state
|
|
33
|
+
engine = Engine.new(config, **arguments)
|
|
34
|
+
report = stdout_on_stderr { engine.call }
|
|
35
|
+
payload = Reporter::Agent.call(report, agent_provenance(report, tree, engine))
|
|
36
|
+
rendered = Reporter::Agent.render(payload)
|
|
37
|
+
# Same publish order as text_run: the JSON is the completion
|
|
38
|
+
# evidence, so it is written only after every other fallible stage,
|
|
39
|
+
# including the agent payload itself.
|
|
40
|
+
validate_history
|
|
41
|
+
write_html(report)
|
|
42
|
+
record_history(report)
|
|
43
|
+
validate_history
|
|
44
|
+
write_json(report, agent_exit_cause(report, payload))
|
|
45
|
+
@out.print(rendered)
|
|
46
|
+
Reporter::Agent.exit_code(payload)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def execute_run
|
|
50
|
+
validate_history
|
|
51
|
+
arguments = { err: @err }
|
|
52
|
+
arguments[:digest] = options[:id] if options[:id]
|
|
53
|
+
agent? ? agent_run(arguments) : text_run(Engine.new(config, **arguments).call)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_history
|
|
57
|
+
path = config.path("score_history")
|
|
58
|
+
return unless path
|
|
59
|
+
|
|
60
|
+
Reporter::HistoryPaths.validate(%w[score_history json_report html_report].map { |key| config.path(key) })
|
|
61
|
+
Reporter::ScoreHistory.new(path).validate_existing
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def record_history(report)
|
|
65
|
+
path = config.path("score_history")
|
|
66
|
+
return unless path
|
|
67
|
+
|
|
68
|
+
tree = WorkingTree.new(config.root, ignored: config.output_paths).state
|
|
69
|
+
scope = config.to_h.except("json_report", "html_report", "score_history", "cache_dir")
|
|
70
|
+
message = Reporter::ScoreHistory.new(path).append(report, head: tree.head, scope: scope)
|
|
71
|
+
@err.puts("saboteur: #{message}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|