saboteur 0.2.0 → 0.4.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 +153 -1
- data/README.md +526 -73
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +305 -30
- data/lib/saboteur/config.rb +65 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +88 -0
- data/lib/saboteur/engine/baseline_guard.rb +116 -0
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +40 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +344 -0
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +125 -25
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +210 -0
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +69 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +14 -3
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +131 -0
- data/lib/saboteur/reporter/html.rb +213 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +21 -4
- data/lib/saboteur/reporter/terminal.rb +76 -2
- data/lib/saboteur/result.rb +61 -9
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +7 -0
- metadata +30 -2
|
@@ -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,40 @@
|
|
|
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
|
+
When a mutant survives:
|
|
24
|
+
|
|
25
|
+
1. Pick one live survivor from `findings.shown`.
|
|
26
|
+
2. Add the smallest test that checks the observable behavior the mutant changed.
|
|
27
|
+
3. Replay it with the command in `next.replay` (it uses the recorded seed) and
|
|
28
|
+
confirm the mutant is now killed.
|
|
29
|
+
4. Run the diff again. Repeat until the exit code is 0.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
|
|
33
|
+
- Report `partial` and `not_applicable` results as they are. Say what had no
|
|
34
|
+
coverage or was capped; do not call an incomplete run clean.
|
|
35
|
+
- Never change production behavior only to kill a mutant.
|
|
36
|
+
- Never add survivors to the ignore list yourself. Ask the owner.
|
|
37
|
+
- Run `saboteur help` for every command, option and exit code.
|
|
38
|
+
MARKDOWN
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
# Two fresh supervised children check the full suite without changing methods
|
|
9
|
+
# or writing caches. This samples stability; it cannot prove absence of flakes.
|
|
10
|
+
class BaselineHealth
|
|
11
|
+
class TestSequence
|
|
12
|
+
attr_reader :executed
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@executed = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_started(_id, _file); end
|
|
19
|
+
|
|
20
|
+
def test_finished(id)
|
|
21
|
+
@executed << id
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(config, integration:, hooks: Hooks.new, supervisor: nil)
|
|
26
|
+
@config = config
|
|
27
|
+
@integration = integration
|
|
28
|
+
@hooks = hooks
|
|
29
|
+
limits = Execution::Supervisor::Limits.new(max_memory_mb: config["max_memory_mb"],
|
|
30
|
+
max_processes: config["max_processes"])
|
|
31
|
+
@supervisor = supervisor || Execution::Supervisor.new(limits: limits)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
expected = @integration.all_tests.map(&:id)
|
|
36
|
+
raise Error, "baseline has no tests; check rspec_args and spec paths" if expected.empty?
|
|
37
|
+
|
|
38
|
+
first = check_run(1, expected)
|
|
39
|
+
second = check_run(2, expected)
|
|
40
|
+
if first != second
|
|
41
|
+
raise Error,
|
|
42
|
+
"baseline repeated with different test order; check random ordering and suite setup"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def check_run(number, expected)
|
|
51
|
+
Tempfile.create("saboteur-baseline") do |log|
|
|
52
|
+
result = @supervisor.run(timeout: @config["baseline_timeout"]) { |writer| record(writer, log.path) }
|
|
53
|
+
data = read_result(result, number)
|
|
54
|
+
verify_execution(data, number, expected)
|
|
55
|
+
raise Error, "baseline run #{number} left processes running; fix suite cleanup" if result.leftover_pids.any?
|
|
56
|
+
|
|
57
|
+
data["executed"]
|
|
58
|
+
end
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
raise Error, "baseline run #{number} returned an invalid result"
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
raise e if e.is_a?(Error)
|
|
63
|
+
|
|
64
|
+
raise Error, "baseline check failed: #{e.class}: #{e.message}; check suite hooks and setup"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def read_result(result, number)
|
|
68
|
+
raise Error, "baseline run #{number} stopped: #{result.detail}" if result.stopped_by
|
|
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?
|
|
71
|
+
raise Error, "baseline run #{number} exited unsuccessfully; fix suite cleanup and exit handlers"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
data = JSON.parse(result.payload)
|
|
75
|
+
raise Error, "baseline run #{number} returned an invalid result" unless data.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
data
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def verify_execution(data, number, expected)
|
|
81
|
+
unless data["passed"] == true
|
|
82
|
+
raise Error, "baseline run #{number} failed: #{data['error'] || 'fix the unchanged suite'}"
|
|
83
|
+
end
|
|
84
|
+
return if data["executed"].is_a?(Array) && data["executed"].sort == expected.sort
|
|
85
|
+
|
|
86
|
+
raise Error, "baseline run #{number} did not execute every requested test; check filters and suite hooks"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def failure_detail
|
|
90
|
+
return nil unless defined?(::RSpec)
|
|
91
|
+
|
|
92
|
+
::RSpec.world.all_examples.filter_map do |example|
|
|
93
|
+
exception = example.execution_result.exception
|
|
94
|
+
next unless exception
|
|
95
|
+
|
|
96
|
+
message = exception.message.to_s.lines.map(&:strip).find { |line| !line.empty? }.to_s[0, 200]
|
|
97
|
+
"#{example.id}: #{example.full_description.to_s[0, 200]}: #{message}"
|
|
98
|
+
end.first(5).join("; ")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def record(writer, log_path)
|
|
102
|
+
$stdout.reopen(log_path, "w")
|
|
103
|
+
$stderr.reopen($stdout)
|
|
104
|
+
@hooks.run(:after_fork)
|
|
105
|
+
Kernel.srand(@config["seed"])
|
|
106
|
+
::RSpec.configuration.order = "rand:#{@config['seed']}" if defined?(::RSpec)
|
|
107
|
+
sequence = TestSequence.new
|
|
108
|
+
passed = @integration.run_all(sequence)
|
|
109
|
+
writer.write(JSON.generate(passed: passed, executed: sequence.executed, error: failure_detail))
|
|
110
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
111
|
+
writer.write(JSON.generate(passed: false, error: "#{e.class}: #{e.message}"))
|
|
112
|
+
ensure
|
|
113
|
+
writer.close
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/saboteur/cli.rb
CHANGED
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
require_relative "../saboteur"
|
|
6
|
+
require_relative "doctor"
|
|
7
|
+
require_relative "help"
|
|
8
|
+
require_relative "reporter/replay"
|
|
6
9
|
|
|
7
10
|
module Saboteur
|
|
8
11
|
class CLI
|
|
9
|
-
USAGE = "Usage: saboteur run [
|
|
12
|
+
USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--threshold PERCENT] [--since REF] " \
|
|
13
|
+
"[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
|
|
14
|
+
" \
|
|
15
|
+
"Run 'saboteur help' for every command, option, config key and exit code."
|
|
10
16
|
|
|
11
17
|
EXIT_OK = 0
|
|
12
18
|
EXIT_SURVIVORS = 1
|
|
13
19
|
EXIT_USAGE = 2
|
|
14
20
|
EXIT_TOOL = 3
|
|
15
|
-
|
|
16
21
|
class UsageError < ConfigError; end
|
|
17
22
|
|
|
18
23
|
attr_reader :options
|
|
@@ -22,12 +27,17 @@ module Saboteur
|
|
|
22
27
|
@out = out
|
|
23
28
|
@err = err
|
|
24
29
|
@options = {}
|
|
30
|
+
@format = "agent" if agent_requested?
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
def call
|
|
28
34
|
case @argv.shift
|
|
29
|
-
when "run" then run
|
|
35
|
+
when "run" then help_requested? ? show_help("run") : run
|
|
36
|
+
when "doctor" then help_requested? ? show_help("doctor") : doctor
|
|
30
37
|
when "version", "--version" then @out.puts(VERSION) || EXIT_OK
|
|
38
|
+
when "help" then show_help(@argv.shift)
|
|
39
|
+
when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
|
|
40
|
+
when "--help", "-h" then show_help(nil)
|
|
31
41
|
else usage_error
|
|
32
42
|
end
|
|
33
43
|
rescue OptionParser::ParseError => e
|
|
@@ -38,23 +48,110 @@ module Saboteur
|
|
|
38
48
|
@config ||= Config.load(root: Dir.pwd, path: options[:config], overrides: overrides)
|
|
39
49
|
end
|
|
40
50
|
|
|
51
|
+
def parser
|
|
52
|
+
OptionParser.new do |opts|
|
|
53
|
+
opts.banner = USAGE
|
|
54
|
+
define_selection_options(opts)
|
|
55
|
+
define_explicit_file_options(opts)
|
|
56
|
+
define_execution_options(opts)
|
|
57
|
+
define_output_options(opts)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Each command's own option lines, as printed under OPTIONS in `saboteur help`.
|
|
62
|
+
def option_lines
|
|
63
|
+
{ "run" => parser.summarize, "doctor" => doctor_parser.summarize }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def doctor_parser
|
|
67
|
+
OptionParser.new do |opts|
|
|
68
|
+
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
|
|
69
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
|
|
70
|
+
opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
41
74
|
private
|
|
42
75
|
|
|
76
|
+
def help_requested?
|
|
77
|
+
@argv.intersect?(%w[--help -h])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def show_help(command_name)
|
|
81
|
+
help = Help.new(option_lines: option_lines)
|
|
82
|
+
text = command_name ? help.command(command_name) : help.overview
|
|
83
|
+
return @out.puts(text) || EXIT_OK if text
|
|
84
|
+
|
|
85
|
+
@err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
|
|
86
|
+
EXIT_USAGE
|
|
87
|
+
end
|
|
88
|
+
|
|
43
89
|
def usage_error(message = nil)
|
|
44
90
|
@err.puts(message) if message
|
|
45
91
|
@err.puts(USAGE)
|
|
46
|
-
EXIT_USAGE
|
|
92
|
+
agent_failure(message || USAGE, EXIT_USAGE)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def agent?
|
|
96
|
+
@format == "agent"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Found before parsing, so any error, even a bad option that comes first, still
|
|
100
|
+
# answers an agent with JSON.
|
|
101
|
+
def agent_requested?
|
|
102
|
+
@argv.include?("--agent") || @argv.include?("--format=agent") ||
|
|
103
|
+
@argv.each_cons(2).any? { |flag, value| flag == "--format" && value == "agent" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# In agent mode the failure is also on stdout, where the agent reads its answer.
|
|
107
|
+
def agent_failure(message, exit_code)
|
|
108
|
+
@out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
|
|
109
|
+
exit_code
|
|
47
110
|
end
|
|
48
111
|
|
|
49
112
|
def run
|
|
50
113
|
parser.parse!(@argv)
|
|
51
|
-
|
|
114
|
+
use_explicit_sources
|
|
115
|
+
reject_threshold_with_agent_format
|
|
116
|
+
|
|
117
|
+
arguments = { err: @err }
|
|
118
|
+
arguments[:digest] = options[:id] if options[:id]
|
|
119
|
+
agent? ? agent_run(arguments) : text_run(Engine.new(config, **arguments).call)
|
|
120
|
+
rescue ConfigError => e
|
|
121
|
+
@err.puts("saboteur: #{e.message}")
|
|
122
|
+
agent_failure(e.message, EXIT_USAGE)
|
|
123
|
+
rescue Error => e
|
|
124
|
+
@err.puts("saboteur: #{e.message}")
|
|
125
|
+
agent_failure(e.message, EXIT_TOOL)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Agent mode has one strict meaning for exit 0, so a score threshold cannot also decide it.
|
|
129
|
+
def reject_threshold_with_agent_format
|
|
130
|
+
return unless agent? && config["threshold"]
|
|
52
131
|
|
|
53
|
-
|
|
132
|
+
raise UsageError, "--threshold cannot be combined with --agent: agent mode exits 0 only for a complete, clean run"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def text_run(report)
|
|
54
136
|
@err.puts
|
|
55
137
|
Reporter::Terminal.new(@out).call(report)
|
|
56
|
-
|
|
57
|
-
exit_code(report)
|
|
138
|
+
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
139
|
+
code = exit_code(report)
|
|
140
|
+
# The JSON carries the exit cause an outer harness trusts, so it is
|
|
141
|
+
# the completion evidence for the whole run: write it only after
|
|
142
|
+
# every other fallible reporting stage, or a late failure would
|
|
143
|
+
# leave a fresh, misleading report behind.
|
|
144
|
+
write_html(report)
|
|
145
|
+
write_json(report, exit_cause(report, code))
|
|
146
|
+
code
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def doctor
|
|
150
|
+
doctor_parser.parse!(@argv)
|
|
151
|
+
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
152
|
+
|
|
153
|
+
Doctor.new(config, out: @out).call
|
|
154
|
+
EXIT_OK
|
|
58
155
|
rescue ConfigError => e
|
|
59
156
|
@err.puts("saboteur: #{e.message}")
|
|
60
157
|
EXIT_USAGE
|
|
@@ -63,41 +160,219 @@ module Saboteur
|
|
|
63
160
|
EXIT_TOOL
|
|
64
161
|
end
|
|
65
162
|
|
|
66
|
-
|
|
163
|
+
# Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
|
|
164
|
+
# files, hooks, spec loading, child processes) goes to stderr while the engine runs.
|
|
165
|
+
def agent_run(arguments)
|
|
166
|
+
tree = WorkingTree.new(config.root, ignored: output_paths).state
|
|
167
|
+
report = stdout_on_stderr { Engine.new(config, **arguments).call }
|
|
168
|
+
payload = Reporter::Agent.call(report, agent_provenance(report, tree))
|
|
169
|
+
rendered = Reporter::Agent.render(payload)
|
|
170
|
+
# Same publish order as text_run: the JSON is the completion
|
|
171
|
+
# evidence, so it is written only after every other fallible stage,
|
|
172
|
+
# including the agent payload itself.
|
|
173
|
+
write_html(report, announce: false)
|
|
174
|
+
write_json(report, agent_exit_cause(report, payload), announce: false)
|
|
175
|
+
@out.print(rendered)
|
|
176
|
+
Reporter::Agent.exit_code(payload)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Paths of saboteur's own output, relative to the root, that do not count as source changes.
|
|
180
|
+
def output_paths
|
|
181
|
+
root = File.join(File.realpath(config.root), "")
|
|
182
|
+
%w[cache_dir json_report html_report].filter_map { |key| config.path(key) }
|
|
183
|
+
.map { |path| File.expand_path(path) }
|
|
184
|
+
.select { |path| path.start_with?(root) }
|
|
185
|
+
.map { |path| path.delete_prefix(root) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
|
|
189
|
+
# children cannot write into the JSON on stdout.
|
|
190
|
+
def stdout_on_stderr
|
|
191
|
+
saved = $stdout.dup
|
|
192
|
+
$stdout.reopen($stderr)
|
|
193
|
+
yield
|
|
194
|
+
ensure
|
|
195
|
+
$stdout.reopen(saved) if saved
|
|
196
|
+
saved&.close
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def agent_provenance(report, tree)
|
|
200
|
+
Reporter::Agent::Provenance.new(
|
|
201
|
+
ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
|
|
202
|
+
changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
|
|
203
|
+
subjects: Array(config["subjects"]), tests: Array(options[:tests]), sources: @sources || [],
|
|
204
|
+
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
205
|
+
incremental: config["incremental"], settings: options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def agent_mode
|
|
210
|
+
return "replay" if options[:id]
|
|
211
|
+
return "diff" if config["since"]
|
|
212
|
+
|
|
213
|
+
@sources&.any? ? "files" : "full"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def write_json(report, cause, announce: true)
|
|
67
217
|
path = config.path("json_report")
|
|
68
218
|
return unless path
|
|
69
219
|
|
|
70
|
-
Reporter::Stryker.new(path).call(report)
|
|
71
|
-
@out.puts "\nJSON report: #{path}"
|
|
220
|
+
Reporter::Stryker.new(path).call(report, exit_cause: cause)
|
|
221
|
+
@out.puts "\nJSON report: #{path}" if announce
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def write_html(report, announce: true)
|
|
225
|
+
path = config.path("html_report")
|
|
226
|
+
return unless path
|
|
227
|
+
|
|
228
|
+
Reporter::Html.new(path).call(report)
|
|
229
|
+
@out.puts "HTML report: #{path}" if announce
|
|
72
230
|
end
|
|
73
231
|
|
|
74
232
|
def exit_code(report)
|
|
233
|
+
return EXIT_TOOL if mostly_errored?(report)
|
|
234
|
+
|
|
235
|
+
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
236
|
+
|
|
237
|
+
failed_quality_gate?(report) ? EXIT_SURVIVORS : EXIT_OK
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def mostly_errored?(report)
|
|
241
|
+
return false unless report.majority_errored?
|
|
242
|
+
|
|
75
243
|
executed = report.executed
|
|
76
|
-
|
|
244
|
+
errors = executed.count { |result| result.status == :error }
|
|
245
|
+
@err.puts("saboteur: #{errors} of #{executed.size} mutants errored, so the run tested almost nothing; " \
|
|
246
|
+
"fix the errors and run again")
|
|
247
|
+
true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Agent mode: the recorded cause is the payload's own verdict, so the
|
|
251
|
+
# JSON and the process exit classify the run the same way. An
|
|
252
|
+
# incomplete run that mostly errored keeps its distinct cause, so the
|
|
253
|
+
# harness rule means the same thing in both modes.
|
|
254
|
+
def agent_exit_cause(report, payload)
|
|
255
|
+
case payload[:verdict]
|
|
256
|
+
when "clean" then "ok"
|
|
257
|
+
when "survivors" then "survivors"
|
|
258
|
+
else report.majority_errored? ? "errorMajority" : "incomplete"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Why the run exits the way it does, recorded in the JSON report so an
|
|
263
|
+
# outer harness can tell "most mutants errored" from the other exit 3s.
|
|
264
|
+
def exit_cause(report, code)
|
|
265
|
+
case code
|
|
266
|
+
when EXIT_OK then "ok"
|
|
267
|
+
when EXIT_SURVIVORS then config["threshold"].nil? ? "survivors" : "belowThreshold"
|
|
268
|
+
else report.majority_errored? ? "errorMajority" : "incompleteUnderThreshold"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# With --threshold the score decides; without it any unignored survivor does.
|
|
273
|
+
def failed_quality_gate?(report)
|
|
274
|
+
threshold = config["threshold"]
|
|
275
|
+
return report.unignored_survivors.any? if threshold.nil?
|
|
276
|
+
|
|
277
|
+
score = report.score
|
|
278
|
+
return missing_score_reported? if score.nil?
|
|
279
|
+
|
|
280
|
+
score * 100 < threshold
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# A gate that cannot measure a score must not pass: CI would go green
|
|
284
|
+
# on a run that tested nothing.
|
|
285
|
+
def missing_score_reported?
|
|
286
|
+
@err.puts("saboteur: no mutant was tested, so there is no score to compare with --threshold")
|
|
287
|
+
true
|
|
288
|
+
end
|
|
77
289
|
|
|
78
|
-
|
|
290
|
+
# Errors and resource limits are outside the score. With a threshold they
|
|
291
|
+
# make the run incomplete, so the score alone cannot pass it.
|
|
292
|
+
def incomplete_under_threshold?(report)
|
|
293
|
+
return false if config["threshold"].nil?
|
|
294
|
+
|
|
295
|
+
count = report.counts.values_at(:error, :resource_limit).sum
|
|
296
|
+
@err.puts("saboteur: #{count} mutant(s) errored or hit a resource limit, so the score is incomplete") if
|
|
297
|
+
count.positive?
|
|
298
|
+
count.positive?
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Positional arguments name the files (or directories) to mutate. They
|
|
302
|
+
# replace the configured include and exclude patterns. Each must exist,
|
|
303
|
+
# so a typo is an error and not a run over zero subjects.
|
|
304
|
+
def use_explicit_sources
|
|
305
|
+
if @argv.empty?
|
|
306
|
+
raise UsageError, "--test needs at least one source file" if options[:tests] && !config["since"]
|
|
307
|
+
|
|
308
|
+
# With --since the changed files are the sources, so --test may
|
|
309
|
+
# stand alone; the files must still exist.
|
|
310
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
311
|
+
return
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
@sources = @argv.dup
|
|
315
|
+
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
316
|
+
options[:exclude] = []
|
|
317
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def source_pattern(source)
|
|
321
|
+
return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
|
|
322
|
+
raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
|
|
323
|
+
raise UsageError, "source file not found: #{source}" unless File.file?(source)
|
|
324
|
+
|
|
325
|
+
glob_escape(source)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# include is a list of glob patterns; a named file must match only itself.
|
|
329
|
+
def glob_escape(path)
|
|
330
|
+
path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
|
|
334
|
+
def require_existing_test(test)
|
|
335
|
+
path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
|
|
336
|
+
raise UsageError, "test file not found: #{test}" unless File.exist?(path)
|
|
79
337
|
end
|
|
80
338
|
|
|
81
339
|
def overrides
|
|
82
|
-
options.except(:config)
|
|
340
|
+
options.except(:config, :id)
|
|
83
341
|
end
|
|
84
342
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
343
|
+
def define_selection_options(opts)
|
|
344
|
+
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
345
|
+
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
346
|
+
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def define_execution_options(opts)
|
|
350
|
+
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
351
|
+
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
352
|
+
opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
|
|
353
|
+
set(:seed, v)
|
|
354
|
+
end
|
|
355
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
356
|
+
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
357
|
+
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
358
|
+
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
359
|
+
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def define_output_options(opts)
|
|
363
|
+
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
364
|
+
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
365
|
+
opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
|
|
366
|
+
"strict exit codes") { |v| @format = v }
|
|
367
|
+
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def define_explicit_file_options(opts)
|
|
371
|
+
opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
|
|
372
|
+
(options[:tests] ||= []) << v
|
|
373
|
+
end
|
|
374
|
+
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
375
|
+
set(:threshold, v)
|
|
101
376
|
end
|
|
102
377
|
end
|
|
103
378
|
|