saboteur 0.4.0 → 0.6.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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -0
  3. data/README.md +427 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +106 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +124 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +113 -122
  19. data/lib/saboteur/config.rb +96 -6
  20. data/lib/saboteur/doctor.rb +64 -9
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +62 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +7 -5
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +89 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +201 -19
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  48. data/lib/saboteur/frameworks/rails.rb +8 -10
  49. data/lib/saboteur/gates.rb +83 -0
  50. data/lib/saboteur/help.rb +32 -7
  51. data/lib/saboteur/ignore_list.rb +5 -0
  52. data/lib/saboteur/inline_suppression.rb +65 -0
  53. data/lib/saboteur/integration/base.rb +1 -0
  54. data/lib/saboteur/integration/focus_guard.rb +83 -0
  55. data/lib/saboteur/integration/rspec.rb +21 -1
  56. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  57. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  58. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  59. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  60. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  61. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  62. data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
  63. data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
  64. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  65. data/lib/saboteur/preview.rb +115 -0
  66. data/lib/saboteur/reporter/agent_commands.rb +19 -5
  67. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  68. data/lib/saboteur/reporter/agent_summary.rb +12 -6
  69. data/lib/saboteur/reporter/findings.rb +4 -19
  70. data/lib/saboteur/reporter/history_paths.rb +38 -0
  71. data/lib/saboteur/reporter/history_policy.rb +35 -0
  72. data/lib/saboteur/reporter/history_record.rb +131 -0
  73. data/lib/saboteur/reporter/html.rb +12 -1
  74. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  75. data/lib/saboteur/reporter/next_steps.rb +30 -0
  76. data/lib/saboteur/reporter/palette.rb +37 -0
  77. data/lib/saboteur/reporter/score_history.rb +135 -0
  78. data/lib/saboteur/reporter/score_table.rb +57 -0
  79. data/lib/saboteur/reporter/stryker.rb +18 -3
  80. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  81. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  82. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  83. data/lib/saboteur/reporter/terminal.rb +140 -79
  84. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  85. data/lib/saboteur/result.rb +44 -6
  86. data/lib/saboteur/selection/coverage_map.rb +119 -13
  87. data/lib/saboteur/selection/git_diff.rb +28 -1
  88. data/lib/saboteur/selection/recorder.rb +55 -13
  89. data/lib/saboteur/subject.rb +40 -4
  90. data/lib/saboteur/subject_finder.rb +62 -12
  91. data/lib/saboteur/version.rb +1 -1
  92. data/lib/saboteur/working_tree.rb +8 -0
  93. data/lib/saboteur.rb +6 -0
  94. metadata +46 -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,106 @@
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
+ @issues << "baseline inputs or operator policy changed; re-propose with baseline propose"
23
+ @state = "incompatible"
24
+ return
25
+ end
26
+
27
+ @categories = classify(baseline, current)
28
+ @score = score_change(baseline, current, epsilon) if epsilon
29
+ @state = outcome(current)
30
+ end
31
+
32
+ def exit_code
33
+ EXITS.fetch(state)
34
+ end
35
+
36
+ private
37
+
38
+ def classify(baseline, current)
39
+ categories = CATEGORY_NAMES.to_h { |name| [name, []] }
40
+ previous = baseline.fetch("accepted").to_h { |entry| [entry.fetch("id"), entry] }
41
+ current.fetch("candidates").each do |entry|
42
+ accepted = previous.delete(entry.fetch("id"))
43
+ name = category(entry, accepted)
44
+ categories.fetch(name) << entry if name
45
+ end
46
+ categories["unmatched"] = previous.values
47
+ categories.each_value { |entries| entries.sort_by! { |entry| order(entry) } }
48
+ categories
49
+ end
50
+
51
+ def category(entry, accepted)
52
+ case entry.fetch("status")
53
+ when "survived"
54
+ return "new" unless accepted
55
+ return "accepted" if entry["context"] == accepted.fetch("context")
56
+
57
+ "changed"
58
+ when "killed" then accepted && "resolved_kills"
59
+ when "timeout" then accepted && "detected_timeouts"
60
+ else "errors"
61
+ end
62
+ end
63
+
64
+ def outcome(current)
65
+ return "incomplete" unless current.fetch("complete") && issues.empty? && !current.fetch("candidates").empty?
66
+ return "incomplete" unless categories.fetch("errors").empty?
67
+ return "review_required" if %w[new changed unmatched].any? { |name| !categories.fetch(name).empty? }
68
+ return "review_required" if score&.fetch("exceeded")
69
+
70
+ "clean"
71
+ end
72
+
73
+ # Percent of scored mutants that were detected, as saboteur's own score
74
+ # counts them: killed and timed out over killed, timed out and survived.
75
+ # An exact Rational, so the drop is compared with epsilon without
76
+ # binary float error.
77
+ def detected_counts(candidates)
78
+ scored = candidates.select { |entry| SCORED_STATUSES.include?(entry.fetch("status")) }
79
+ return nil if scored.empty?
80
+
81
+ [scored.count { |entry| DETECTED_STATUSES.include?(entry.fetch("status")) }, scored.size]
82
+ end
83
+
84
+ def score_change(baseline, current, epsilon)
85
+ before = detected_counts(baseline.fetch("candidates"))
86
+ now = detected_counts(current.fetch("candidates"))
87
+ return nil unless before && now
88
+
89
+ accepted = Rational(before[0] * 100, before[1])
90
+ measured = Rational(now[0] * 100, now[1])
91
+ drop = accepted - measured
92
+ # The epsilon as the decimal the user typed, not its binary float. The
93
+ # counts let a reader recompute the verdict exactly.
94
+ { "baseline" => accepted.to_f, "current" => measured.to_f, "drop" => drop.to_f, "epsilon" => epsilon,
95
+ "exceeded" => drop > Rational(epsilon.to_s),
96
+ "baseline_detected" => before[0], "baseline_scored" => before[1],
97
+ "current_detected" => now[0], "current_scored" => now[1] }
98
+ end
99
+
100
+ def order(entry)
101
+ identity = entry.fetch("identity", [])
102
+ [identity[1].to_s, identity[2].to_s, entry.fetch("id")]
103
+ end
104
+ end
105
+ end
106
+ 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,124 @@
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
+ validate_operator_policy(config)
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_operator_policy(config)
73
+ unless config["operator_policy"] && config["operator_policy"]["version"] == MutationSource::OperatorRegistry::POLICY_VERSION
74
+ raise ConfigError, "baseline operator policy is incompatible; re-propose with baseline propose"
75
+ end
76
+
77
+ fields!(config, Config::DEFAULTS.keys - RunEvidence::OUTPUT_SETTINGS + ["operator_policy"])
78
+ expected = Config.new(config.except("operator_policy")).operator_policy
79
+ raise ConfigError, "baseline operator policy mismatch" unless expected == config["operator_policy"]
80
+ end
81
+
82
+ def validate_generator(generator, config, runtime)
83
+ fields!(generator, %w[saboteur sources plugins])
84
+ plugins = document.fetch("snapshot").fetch("inventory").select { |entry| entry["kind"] == "source_plugin" }
85
+ raise ConfigError, "baseline generator mismatch" unless
86
+ generator["saboteur"] == runtime["saboteur"] && generator["sources"] == config["sources"] &&
87
+ generator["plugins"] == plugins
88
+ end
89
+
90
+ def digest(value)
91
+ Digest::SHA256.hexdigest(JSON.generate(Engine::RunFingerprint.canonical_value(value)))
92
+ end
93
+
94
+ def validate_generation
95
+ clear = document.values_at("omissions", "discovery_failures", "inline_suppressions").all? do |entries|
96
+ entries == []
97
+ end
98
+ raise ConfigError, "baseline omitted or undiscovered work (including inline suppressions)" unless clear
99
+
100
+ events = document.fetch("generation_events")
101
+ array!(events, "generation events")
102
+
103
+ events.each do |event|
104
+ fields!(event, %w[file subject source operator reason])
105
+ raise ConfigError, "baseline incomplete generation" unless RunEvidence::SAFE_DROPS.include?(event["reason"])
106
+ end
107
+ end
108
+
109
+ def validate_plan
110
+ plan = document.fetch("planned")
111
+ array!(plan, "candidate plan")
112
+ raise ConfigError, "baseline empty candidate plan" if plan.empty?
113
+
114
+ plan.each { |entry| fields!(entry, PLAN_FIELDS) }
115
+ results = document.fetch("candidates")
116
+ array!(results, "candidates")
117
+
118
+ expected = results.map { |entry| entry.slice(*PLAN_FIELDS) }
119
+ raise ConfigError, "baseline planned results differ" unless
120
+ plan.sort_by { |entry| entry.fetch("id") } == expected.sort_by { |entry| entry.fetch("id") }
121
+ end
122
+ end
123
+ end
124
+ 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