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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +280 -1
  3. data/README.md +635 -77
  4. data/exe/saboteur +6 -2
  5. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  6. data/lib/saboteur/agent_skill.rb +46 -0
  7. data/lib/saboteur/baseline/acceptance.rb +218 -0
  8. data/lib/saboteur/baseline/command.rb +80 -0
  9. data/lib/saboteur/baseline/comparison.rb +105 -0
  10. data/lib/saboteur/baseline/completeness.rb +53 -0
  11. data/lib/saboteur/baseline/evidence.rb +114 -0
  12. data/lib/saboteur/baseline/identity.rb +78 -0
  13. data/lib/saboteur/baseline/manifest.rb +164 -0
  14. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  15. data/lib/saboteur/baseline/workflow.rb +121 -0
  16. data/lib/saboteur/baseline_health.rb +1 -1
  17. data/lib/saboteur/cli/exit_status.rb +93 -0
  18. data/lib/saboteur/cli/run_reporting.rb +75 -0
  19. data/lib/saboteur/cli.rb +171 -63
  20. data/lib/saboteur/config.rb +101 -19
  21. data/lib/saboteur/config_file.rb +97 -0
  22. data/lib/saboteur/custom_map.rb +170 -0
  23. data/lib/saboteur/doctor.rb +57 -9
  24. data/lib/saboteur/engine/baseline_guard.rb +44 -5
  25. data/lib/saboteur/engine/boot.rb +9 -0
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  27. data/lib/saboteur/engine/canary.rb +74 -0
  28. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  29. data/lib/saboteur/engine/candidates.rb +59 -7
  30. data/lib/saboteur/engine/coverage_store.rb +53 -6
  31. data/lib/saboteur/engine/data_inputs.rb +43 -0
  32. data/lib/saboteur/engine/diagnostics.rb +176 -2
  33. data/lib/saboteur/engine/diff_scope.rb +18 -0
  34. data/lib/saboteur/engine/distribution.rb +54 -13
  35. data/lib/saboteur/engine/result_cache.rb +10 -5
  36. data/lib/saboteur/engine/run_fingerprint.rb +40 -10
  37. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  38. data/lib/saboteur/engine/scheduler.rb +99 -36
  39. data/lib/saboteur/engine/subject_selection.rb +24 -1
  40. data/lib/saboteur/engine/suite_files.rb +8 -1
  41. data/lib/saboteur/engine/test_guess.rb +52 -0
  42. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  43. data/lib/saboteur/engine/verdict_check.rb +7 -6
  44. data/lib/saboteur/engine.rb +257 -22
  45. data/lib/saboteur/execution/protocol.rb +2 -1
  46. data/lib/saboteur/execution/reevaluator.rb +79 -14
  47. data/lib/saboteur/execution/runner.rb +120 -17
  48. data/lib/saboteur/execution/sandbox.rb +271 -0
  49. data/lib/saboteur/execution/stale_captures.rb +43 -0
  50. data/lib/saboteur/execution/supervisor.rb +113 -9
  51. data/lib/saboteur/execution/worker_pool.rb +20 -4
  52. data/lib/saboteur/execution/wrapper.rb +45 -0
  53. data/lib/saboteur/frameworks/rails.rb +329 -13
  54. data/lib/saboteur/gates.rb +83 -0
  55. data/lib/saboteur/help.rb +63 -23
  56. data/lib/saboteur/ignore_list.rb +76 -17
  57. data/lib/saboteur/inline_suppression.rb +65 -0
  58. data/lib/saboteur/install.rb +132 -0
  59. data/lib/saboteur/integration/base.rb +1 -0
  60. data/lib/saboteur/integration/focus_guard.rb +83 -0
  61. data/lib/saboteur/integration/rspec.rb +68 -4
  62. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  63. data/lib/saboteur/mutation_source/builtin.rb +30 -5
  64. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  65. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  67. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  68. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  69. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  70. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  71. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  72. data/lib/saboteur/preview.rb +106 -0
  73. data/lib/saboteur/replay.rb +1 -1
  74. data/lib/saboteur/reporter/agent.rb +87 -0
  75. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  76. data/lib/saboteur/reporter/agent_commands.rb +67 -0
  77. data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
  78. data/lib/saboteur/reporter/agent_summary.rb +137 -0
  79. data/lib/saboteur/reporter/findings.rb +12 -19
  80. data/lib/saboteur/reporter/history_paths.rb +38 -0
  81. data/lib/saboteur/reporter/history_record.rb +132 -0
  82. data/lib/saboteur/reporter/html.rb +61 -2
  83. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  84. data/lib/saboteur/reporter/next_steps.rb +30 -0
  85. data/lib/saboteur/reporter/palette.rb +37 -0
  86. data/lib/saboteur/reporter/score_history.rb +123 -0
  87. data/lib/saboteur/reporter/score_table.rb +57 -0
  88. data/lib/saboteur/reporter/stryker.rb +25 -5
  89. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  90. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  91. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  92. data/lib/saboteur/reporter/terminal.rb +157 -42
  93. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  94. data/lib/saboteur/result.rb +86 -10
  95. data/lib/saboteur/score.rb +89 -0
  96. data/lib/saboteur/selection/coverage_map.rb +119 -13
  97. data/lib/saboteur/selection/git_diff.rb +28 -1
  98. data/lib/saboteur/selection/recorder.rb +55 -13
  99. data/lib/saboteur/subject.rb +40 -4
  100. data/lib/saboteur/subject_finder.rb +62 -12
  101. data/lib/saboteur/version.rb +1 -1
  102. data/lib/saboteur/working_tree.rb +56 -0
  103. data/lib/saboteur.rb +10 -0
  104. 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. It stays suspended (no counting) except while the coverage map is built.
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