saboteur 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/README.md +393 -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 +105 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +114 -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 +101 -121
  19. data/lib/saboteur/config.rb +76 -6
  20. data/lib/saboteur/doctor.rb +40 -8
  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 +59 -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 +6 -4
  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 +52 -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 +192 -18
  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/gates.rb +83 -0
  48. data/lib/saboteur/help.rb +31 -7
  49. data/lib/saboteur/ignore_list.rb +5 -0
  50. data/lib/saboteur/inline_suppression.rb +65 -0
  51. data/lib/saboteur/integration/base.rb +1 -0
  52. data/lib/saboteur/integration/focus_guard.rb +83 -0
  53. data/lib/saboteur/integration/rspec.rb +21 -1
  54. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  55. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  56. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  57. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  58. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  59. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  60. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  61. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  62. data/lib/saboteur/preview.rb +106 -0
  63. data/lib/saboteur/reporter/agent_commands.rb +7 -1
  64. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  65. data/lib/saboteur/reporter/agent_summary.rb +11 -5
  66. data/lib/saboteur/reporter/findings.rb +4 -19
  67. data/lib/saboteur/reporter/history_paths.rb +38 -0
  68. data/lib/saboteur/reporter/history_record.rb +132 -0
  69. data/lib/saboteur/reporter/html.rb +12 -1
  70. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  71. data/lib/saboteur/reporter/next_steps.rb +30 -0
  72. data/lib/saboteur/reporter/palette.rb +37 -0
  73. data/lib/saboteur/reporter/score_history.rb +123 -0
  74. data/lib/saboteur/reporter/score_table.rb +57 -0
  75. data/lib/saboteur/reporter/stryker.rb +15 -2
  76. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  77. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  78. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  79. data/lib/saboteur/reporter/terminal.rb +131 -79
  80. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  81. data/lib/saboteur/result.rb +41 -6
  82. data/lib/saboteur/selection/coverage_map.rb +119 -13
  83. data/lib/saboteur/selection/git_diff.rb +28 -1
  84. data/lib/saboteur/selection/recorder.rb +55 -13
  85. data/lib/saboteur/subject.rb +40 -4
  86. data/lib/saboteur/subject_finder.rb +62 -12
  87. data/lib/saboteur/version.rb +1 -1
  88. data/lib/saboteur/working_tree.rb +8 -0
  89. data/lib/saboteur.rb +6 -0
  90. metadata +43 -2
@@ -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.exit_status.is_a?(Process::Status) && !result.exit_status.success?
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