saboteur 0.8.1 → 0.9.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 +100 -0
- data/README.md +10 -0
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +3 -0
- data/lib/saboteur/baseline/run_evidence.rb +2 -1
- data/lib/saboteur/cli/agent_provenance.rb +59 -0
- data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
- data/lib/saboteur/cli/doctor_command.rb +41 -0
- data/lib/saboteur/cli/error_cause.rb +40 -0
- data/lib/saboteur/cli/execution_options.rb +46 -0
- data/lib/saboteur/cli/exit_status.rb +12 -14
- data/lib/saboteur/cli/explicit_file_options.rb +21 -0
- data/lib/saboteur/cli/explicit_sources.rb +46 -0
- data/lib/saboteur/cli/failure_output.rb +35 -0
- data/lib/saboteur/cli/help_command.rb +23 -0
- data/lib/saboteur/cli/matrix_option.rb +17 -0
- data/lib/saboteur/cli/output_options.rb +27 -0
- data/lib/saboteur/cli/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +3 -0
- data/lib/saboteur/cli/run_command.rb +33 -0
- data/lib/saboteur/cli/run_output.rb +38 -0
- data/lib/saboteur/cli/run_reporting.rb +6 -5
- data/lib/saboteur/cli/run_validation.rb +54 -0
- data/lib/saboteur/cli.rb +29 -307
- data/lib/saboteur/config.rb +72 -6
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +20 -12
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +1 -1
- data/lib/saboteur/doctor.rb +6 -0
- data/lib/saboteur/engine/baseline_guard.rb +50 -13
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +15 -5
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
- data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
- data/lib/saboteur/engine/coverage_partial.rb +48 -0
- data/lib/saboteur/engine/coverage_parts.rb +50 -0
- data/lib/saboteur/engine/coverage_shadow.rb +92 -0
- data/lib/saboteur/engine/coverage_store.rb +275 -22
- data/lib/saboteur/engine/diagnostics.rb +23 -6
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +1 -0
- data/lib/saboteur/engine/load_coverage.rb +54 -8
- data/lib/saboteur/engine/partial_invalidation.rb +115 -0
- data/lib/saboteur/engine/partial_merge.rb +40 -0
- data/lib/saboteur/engine/pipelines.rb +7 -7
- data/lib/saboteur/engine/reference_specs.rb +71 -0
- data/lib/saboteur/engine/result_cache.rb +68 -5
- data/lib/saboteur/engine/resume_manifest.rb +110 -0
- data/lib/saboteur/engine/run_budget.rb +5 -5
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +5 -2
- data/lib/saboteur/engine/scheduler.rb +1 -1
- data/lib/saboteur/engine/selection_announcement.rb +1 -0
- data/lib/saboteur/engine/shadow_comparison.rb +121 -0
- data/lib/saboteur/engine/shadow_run.rb +38 -0
- data/lib/saboteur/engine/subject_selection.rb +33 -37
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +82 -18
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +41 -9
- data/lib/saboteur/environment_hints.rb +55 -0
- data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
- data/lib/saboteur/execution/external_runner.rb +105 -1
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
- data/lib/saboteur/execution/sandbox.rb +30 -17
- data/lib/saboteur/execution/supervisor.rb +40 -7
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- data/lib/saboteur/gates.rb +121 -28
- data/lib/saboteur/install.rb +6 -1
- data/lib/saboteur/integration/base.rb +19 -2
- data/lib/saboteur/integration/rspec.rb +46 -7
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/builtin.rb +32 -22
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- data/lib/saboteur/mutation_source/jump_value.rb +59 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
- data/lib/saboteur/mutation_source/operator_examples.rb +5 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -0
- data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
- data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
- data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
- data/lib/saboteur/mutation_source/when_removal.rb +51 -0
- data/lib/saboteur/preview.rb +9 -1
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +16 -10
- data/lib/saboteur/reporter/review_comments.rb +151 -0
- data/lib/saboteur/reporter/safe_out.rb +32 -0
- data/lib/saboteur/reporter/stryker.rb +8 -6
- data/lib/saboteur/reporter/survivor_classification.rb +41 -3
- data/lib/saboteur/reporter/survivor_hints.rb +5 -0
- data/lib/saboteur/reporter/terminal.rb +29 -4
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +136 -10
- data/lib/saboteur/selection/external_test_map.rb +4 -0
- data/lib/saboteur/selection/git_diff.rb +24 -2
- data/lib/saboteur/selection/layout_only_lines.rb +45 -1
- data/lib/saboteur/selection/recorder.rb +52 -22
- data/lib/saboteur/selection/trace_recorder.rb +83 -0
- data/lib/saboteur/selection/untracked_changes.rb +1 -2
- data/lib/saboteur/subject.rb +16 -0
- data/lib/saboteur/subject_finder.rb +91 -6
- data/lib/saboteur/text.rb +31 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +7 -2
- data/lib/saboteur.rb +8 -0
- metadata +44 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# `run --dry-run` and `run --list-operators`: plan output, no mutations
|
|
7
|
+
# (#334). Extracted from CLI by command.
|
|
8
|
+
module PreviewCommand
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def preview
|
|
12
|
+
raise UsageError, "--dry-run and --list-operators print text, not --agent JSON; use --format json" if agent?
|
|
13
|
+
raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
|
|
14
|
+
|
|
15
|
+
if options[:list_operators]
|
|
16
|
+
MutationSource::OperatorRegistry.list(@out, names: config["operators"],
|
|
17
|
+
excluded: config["exclude_operators"])
|
|
18
|
+
return EXIT_OK
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Preview.new(config, out: @out, err: @err, format: @format).call
|
|
22
|
+
EXIT_OK
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -13,6 +13,9 @@ module Saboteur
|
|
|
13
13
|
opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
|
|
14
14
|
set(:seed, v)
|
|
15
15
|
end
|
|
16
|
+
opts.on("--coverage-parts N", Integer, "Build the coverage map in N parallel parts (default 1)") do |v|
|
|
17
|
+
set(:coverage_parts, v)
|
|
18
|
+
end
|
|
16
19
|
opts.on("--baseline-passes N", Integer, "Unchanged-suite checks (default 2)") { |v| set(:baseline_passes, v) }
|
|
17
20
|
opts.on("--max-duration DURATION", "Wall-clock budget for the whole run, e.g. 10m; exit 4 when spent - " \
|
|
18
21
|
"the clock starts at engine entry, so boot, baselines and the " \
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# `saboteur run`: parse, validate, then preview or execute (#334).
|
|
7
|
+
# Extracted from CLI by command.
|
|
8
|
+
module RunCommand
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def run_command = help_requested? ? show_help("run") : run
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
parser.parse!(@argv)
|
|
15
|
+
reject_test_selection_conflicts
|
|
16
|
+
use_explicit_sources
|
|
17
|
+
validate_run_options
|
|
18
|
+
warn_dirty_tree
|
|
19
|
+
|
|
20
|
+
config.deprecation_warnings.each { |warning| @err.puts("saboteur: #{warning}") }
|
|
21
|
+
|
|
22
|
+
return preview if options[:list_operators] || options[:dry_run]
|
|
23
|
+
|
|
24
|
+
refuse_empty_source_set
|
|
25
|
+
execute_run
|
|
26
|
+
rescue ConfigError => e
|
|
27
|
+
report_failure(e, EXIT_USAGE)
|
|
28
|
+
rescue Error => e
|
|
29
|
+
report_failure(e, EXIT_TOOL)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Where a run's own output goes: the report names it prints and the
|
|
7
|
+
# stdout redirect that keeps agent JSON clean (#334). Extracted from CLI.
|
|
8
|
+
module RunOutput
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# One line naming the reports written, paths relative to the root when
|
|
12
|
+
# they sit under it (a blank line before it, as the old JSON line had).
|
|
13
|
+
def announce_reports(reports)
|
|
14
|
+
return if (written = reports.select { |path, _label| path }).empty?
|
|
15
|
+
|
|
16
|
+
root = File.join(File.realpath(config.root), "")
|
|
17
|
+
names = written.map do |path, label|
|
|
18
|
+
shown = File.expand_path(path).delete_prefix(root)
|
|
19
|
+
"#{shown} (#{label})"
|
|
20
|
+
end
|
|
21
|
+
@out.puts "\nReports: #{names.join(', ')}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
|
|
25
|
+
# files, hooks, spec loading, child processes) goes to stderr while the engine runs.
|
|
26
|
+
# Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
|
|
27
|
+
# children cannot write into the JSON on stdout.
|
|
28
|
+
def stdout_on_stderr
|
|
29
|
+
saved = $stdout.dup
|
|
30
|
+
$stdout.reopen($stderr)
|
|
31
|
+
yield
|
|
32
|
+
ensure
|
|
33
|
+
$stdout.reopen(saved) if saved
|
|
34
|
+
saved&.close
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -30,7 +30,7 @@ module Saboteur
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def agent_run(arguments)
|
|
33
|
-
tree = WorkingTree.new(config.root, ignored: config.
|
|
33
|
+
tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
|
|
34
34
|
engine = Engine.new(config, **arguments)
|
|
35
35
|
report = stdout_on_stderr { engine.call }
|
|
36
36
|
payload = Reporter::Agent.call(report, agent_provenance(report, tree, engine))
|
|
@@ -65,7 +65,7 @@ module Saboteur
|
|
|
65
65
|
# Computed lazily: only a run with survivors asks for it.
|
|
66
66
|
def survivor_replay_command(report, engine)
|
|
67
67
|
lambda do |digest|
|
|
68
|
-
tree = WorkingTree.new(config.root, ignored: config.
|
|
68
|
+
tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
|
|
69
69
|
Reporter::Agent::Commands.replay(agent_provenance(report, tree, engine), digest, capped: false)
|
|
70
70
|
end
|
|
71
71
|
end
|
|
@@ -82,7 +82,8 @@ module Saboteur
|
|
|
82
82
|
path = config.path("json_report")
|
|
83
83
|
return unless path
|
|
84
84
|
|
|
85
|
-
Reporter::Stryker.new(path).call(report, exit_cause: cause
|
|
85
|
+
Reporter::Stryker.new(path).call(report, exit_cause: cause,
|
|
86
|
+
score_gates: gates.score_gate_breakdowns(report))
|
|
86
87
|
path
|
|
87
88
|
rescue SystemCallError, IOError => e
|
|
88
89
|
if config.explicit?("json_report")
|
|
@@ -124,8 +125,8 @@ module Saboteur
|
|
|
124
125
|
path = config.path("score_history")
|
|
125
126
|
return unless path
|
|
126
127
|
|
|
127
|
-
tree = WorkingTree.new(config.root, ignored: config.
|
|
128
|
-
scope = config.selection_settings.except(
|
|
128
|
+
tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
|
|
129
|
+
scope = config.selection_settings.except(*Config::OUTPUT_KEYS)
|
|
129
130
|
# Recording is an auxiliary artifact: when the file cannot be
|
|
130
131
|
# written - read-only tree inside a sandboxed child (#296) - warn
|
|
131
132
|
# and let the run exit by its results instead of failing as a tool
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The usage errors `saboteur run` finds before the target boots: option
|
|
7
|
+
# combinations that contradict each other (#334). Extracted from CLI.
|
|
8
|
+
module RunValidation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Contradictory selection flags are a usage error before any file or
|
|
12
|
+
# repository check: --test wins over inference by precedence, but only
|
|
13
|
+
# one of them may be named. Config-file combinations are not errors:
|
|
14
|
+
# --test wins there, so generated commands stay valid over any config.
|
|
15
|
+
def reject_test_selection_conflicts
|
|
16
|
+
raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
|
|
17
|
+
|
|
18
|
+
reject_test_command_conflicts
|
|
19
|
+
raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
|
|
20
|
+
return unless options[:guess_tests] && options[:tests]&.any?
|
|
21
|
+
|
|
22
|
+
raise UsageError, "--guess-tests cannot be combined with --test"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# --test-command replaces the whole in-process test machinery: test
|
|
26
|
+
# selection, coverage, parallelism and the sandbox do not apply.
|
|
27
|
+
def reject_test_command_conflicts
|
|
28
|
+
return unless options[:test_command]
|
|
29
|
+
|
|
30
|
+
# options[:tests] is nil or a non-empty list, so truthiness is the check.
|
|
31
|
+
raise UsageError, "--test-command cannot be combined with --test" if options[:tests]
|
|
32
|
+
raise UsageError, "--test-command cannot be combined with --incremental" if options[:incremental]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Usage errors found before the target boots.
|
|
36
|
+
def validate_run_options
|
|
37
|
+
reject_format_conflicts
|
|
38
|
+
validate_shard_options
|
|
39
|
+
Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
|
|
43
|
+
def reject_format_conflicts
|
|
44
|
+
if agent? && (config["threshold"] || gates.any?)
|
|
45
|
+
raise UsageError, "#{config['threshold'] ? '--threshold' : gates.agent_option_name} " \
|
|
46
|
+
"cannot be combined with --agent"
|
|
47
|
+
end
|
|
48
|
+
return unless @format == "json" && !options[:dry_run]
|
|
49
|
+
|
|
50
|
+
raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/saboteur/cli.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "doctor"
|
|
|
8
8
|
require_relative "help"
|
|
9
9
|
require_relative "reporter/replay"
|
|
10
10
|
require_relative "baseline/command"
|
|
11
|
+
require_relative "cli/error_cause"
|
|
11
12
|
require_relative "cli/exit_status"
|
|
12
13
|
require_relative "preview"
|
|
13
14
|
require_relative "cli/run_reporting"
|
|
@@ -19,6 +20,20 @@ require_relative "cli/selection_options"
|
|
|
19
20
|
require_relative "cli/presets"
|
|
20
21
|
require_relative "cli/help_options"
|
|
21
22
|
require_relative "cli/history_command"
|
|
23
|
+
require_relative "cli/execution_options"
|
|
24
|
+
require_relative "cli/matrix_option"
|
|
25
|
+
require_relative "cli/output_options"
|
|
26
|
+
require_relative "cli/explicit_file_options"
|
|
27
|
+
require_relative "cli/doctor_command"
|
|
28
|
+
require_relative "cli/agent_provenance"
|
|
29
|
+
require_relative "cli/preview_command"
|
|
30
|
+
require_relative "cli/help_command"
|
|
31
|
+
require_relative "cli/run_validation"
|
|
32
|
+
require_relative "cli/explicit_sources"
|
|
33
|
+
require_relative "cli/run_output"
|
|
34
|
+
require_relative "cli/dirty_tree_notice"
|
|
35
|
+
require_relative "cli/run_command"
|
|
36
|
+
require_relative "cli/failure_output"
|
|
22
37
|
|
|
23
38
|
module Saboteur
|
|
24
39
|
class CLI
|
|
@@ -32,7 +47,21 @@ module Saboteur
|
|
|
32
47
|
include SelectionOptions
|
|
33
48
|
include Presets
|
|
34
49
|
include HistoryCommand
|
|
50
|
+
include ExecutionOptions
|
|
51
|
+
include MatrixOption
|
|
52
|
+
include OutputOptions
|
|
53
|
+
include ExplicitFileOptions
|
|
54
|
+
include DoctorCommand
|
|
55
|
+
include AgentProvenance
|
|
35
56
|
include HelpOptions
|
|
57
|
+
include PreviewCommand
|
|
58
|
+
include HelpCommand
|
|
59
|
+
include RunValidation
|
|
60
|
+
include ExplicitSources
|
|
61
|
+
include RunOutput
|
|
62
|
+
include DirtyTreeNotice
|
|
63
|
+
include RunCommand
|
|
64
|
+
include FailureOutput
|
|
36
65
|
|
|
37
66
|
USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--all] [--threshold PERCENT] [--since REF] " \
|
|
38
67
|
"[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
|
|
@@ -96,321 +125,14 @@ module Saboteur
|
|
|
96
125
|
end
|
|
97
126
|
end
|
|
98
127
|
|
|
99
|
-
def doctor_parser
|
|
100
|
-
OptionParser.new do |opts|
|
|
101
|
-
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
|
|
102
|
-
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
|
|
103
|
-
opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
|
|
104
|
-
opts.on("--suite", "Also run the unchanged suite twice (slow on a large suite)") { set(:suite, true) }
|
|
105
|
-
opts.on("--test PATH", "Spec file or directory for the suite runs; repeatable. Implies --suite") do |value|
|
|
106
|
-
(options[:tests] ||= []) << value
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
128
|
private
|
|
112
129
|
|
|
113
|
-
def run_command = help_requested? ? show_help("run") : run
|
|
114
|
-
|
|
115
|
-
def doctor_command = help_requested? ? show_help("doctor") : doctor
|
|
116
|
-
|
|
117
130
|
def history_entry(command) = help_requested? ? show_help(command) : history_command(command)
|
|
118
131
|
|
|
119
|
-
def help_requested? = @argv.intersect?(%w[--help -h])
|
|
120
|
-
|
|
121
|
-
def show_help(command_name)
|
|
122
|
-
help = Help.new(option_lines: option_lines)
|
|
123
|
-
text = command_name ? help.command(command_name) : help.overview
|
|
124
|
-
return @out.puts(text) || EXIT_OK if text
|
|
125
|
-
|
|
126
|
-
@err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
|
|
127
|
-
EXIT_USAGE
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def usage_error(message = nil)
|
|
131
|
-
@err.puts(message) if message
|
|
132
|
-
@err.puts(USAGE)
|
|
133
|
-
agent_failure(message || USAGE, EXIT_USAGE)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def agent? = @format == "agent"
|
|
137
|
-
|
|
138
132
|
def agent_requested? = self.class.agent_requested?(@argv)
|
|
139
133
|
|
|
140
|
-
# In agent mode the failure is also on stdout, where the agent reads its answer.
|
|
141
|
-
def agent_failure(message, exit_code)
|
|
142
|
-
@out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
|
|
143
|
-
exit_code
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def run
|
|
147
|
-
parser.parse!(@argv)
|
|
148
|
-
reject_test_selection_conflicts
|
|
149
|
-
use_explicit_sources
|
|
150
|
-
validate_run_options
|
|
151
|
-
warn_dirty_tree
|
|
152
|
-
|
|
153
|
-
config.deprecation_warnings.each { |warning| @err.puts("saboteur: #{warning}") }
|
|
154
|
-
|
|
155
|
-
return preview if options[:list_operators] || options[:dry_run]
|
|
156
|
-
|
|
157
|
-
refuse_empty_source_set
|
|
158
|
-
execute_run
|
|
159
|
-
rescue ConfigError => e
|
|
160
|
-
@err.puts("saboteur: #{e.message}")
|
|
161
|
-
agent_failure(e.message, EXIT_USAGE)
|
|
162
|
-
rescue Error => e
|
|
163
|
-
@err.puts("saboteur: #{e.message}")
|
|
164
|
-
agent_failure(e.message, EXIT_TOOL)
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# Mutants run your code, and a mutant of code that writes or deletes files
|
|
168
|
-
# can change the tree. Say so up front when uncommitted work is at stake.
|
|
169
|
-
# The scope the warning names is the configured one: positional source
|
|
170
|
-
# operands narrow the run after the warning, so the include/exclude they
|
|
171
|
-
# imply stay out of its config (#309, #503 review).
|
|
172
|
-
def warn_dirty_tree
|
|
173
|
-
warning = @sources ? Config.load(path: options[:config], overrides: overrides.except(:include, :exclude)) : config
|
|
174
|
-
DirtyTreeWarning.for(warning, config_path: options[:config])&.then { |text| @err.puts(text) }
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
# Contradictory selection flags are a usage error before any file or
|
|
178
|
-
# repository check: --test wins over inference by precedence, but only
|
|
179
|
-
# one of them may be named. Config-file combinations are not errors:
|
|
180
|
-
# --test wins there, so generated commands stay valid over any config.
|
|
181
|
-
def reject_test_selection_conflicts
|
|
182
|
-
raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
|
|
183
|
-
|
|
184
|
-
reject_test_command_conflicts
|
|
185
|
-
raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
|
|
186
|
-
return unless options[:guess_tests] && options[:tests]&.any?
|
|
187
|
-
|
|
188
|
-
raise UsageError, "--guess-tests cannot be combined with --test"
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# --test-command replaces the whole in-process test machinery: test
|
|
192
|
-
# selection, coverage, parallelism and the sandbox do not apply.
|
|
193
|
-
def reject_test_command_conflicts
|
|
194
|
-
return unless options[:test_command]
|
|
195
|
-
|
|
196
|
-
# options[:tests] is nil or a non-empty list, so truthiness is the check.
|
|
197
|
-
raise UsageError, "--test-command cannot be combined with --test" if options[:tests]
|
|
198
|
-
raise UsageError, "--test-command cannot be combined with --incremental" if options[:incremental]
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# Usage errors found before the target boots.
|
|
202
|
-
def validate_run_options
|
|
203
|
-
reject_format_conflicts
|
|
204
|
-
validate_shard_options
|
|
205
|
-
Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
|
|
209
|
-
def reject_format_conflicts
|
|
210
|
-
if agent? && (config["threshold"] || gates.any?)
|
|
211
|
-
raise UsageError, "#{config['threshold'] ? '--threshold' : gates.agent_option_name} " \
|
|
212
|
-
"cannot be combined with --agent"
|
|
213
|
-
end
|
|
214
|
-
return unless @format == "json" && !options[:dry_run]
|
|
215
|
-
|
|
216
|
-
raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# One line naming the reports written, paths relative to the root when
|
|
220
|
-
# they sit under it (a blank line before it, as the old JSON line had).
|
|
221
|
-
def announce_reports(reports)
|
|
222
|
-
return if (written = reports.select { |path, _label| path }).empty?
|
|
223
|
-
|
|
224
|
-
root = File.join(File.realpath(config.root), "")
|
|
225
|
-
names = written.map do |path, label|
|
|
226
|
-
shown = File.expand_path(path).delete_prefix(root)
|
|
227
|
-
"#{shown} (#{label})"
|
|
228
|
-
end
|
|
229
|
-
@out.puts "\nReports: #{names.join(', ')}"
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def doctor
|
|
233
|
-
doctor_parser.parse!(@argv)
|
|
234
|
-
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
235
|
-
|
|
236
|
-
options[:tests]&.each { |test| require_existing_test(test) }
|
|
237
|
-
Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
|
|
238
|
-
EXIT_OK
|
|
239
|
-
rescue ConfigError => e
|
|
240
|
-
@err.puts("saboteur: #{e.message}")
|
|
241
|
-
EXIT_USAGE
|
|
242
|
-
rescue Error => e
|
|
243
|
-
@err.puts("saboteur: #{e.message}")
|
|
244
|
-
EXIT_TOOL
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
# Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
|
|
248
|
-
# files, hooks, spec loading, child processes) goes to stderr while the engine runs.
|
|
249
|
-
# Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
|
|
250
|
-
# children cannot write into the JSON on stdout.
|
|
251
|
-
def stdout_on_stderr
|
|
252
|
-
saved = $stdout.dup
|
|
253
|
-
$stdout.reopen($stderr)
|
|
254
|
-
yield
|
|
255
|
-
ensure
|
|
256
|
-
$stdout.reopen(saved) if saved
|
|
257
|
-
saved&.close
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
def agent_provenance(report, tree, engine)
|
|
261
|
-
Reporter::Agent::Provenance.new(
|
|
262
|
-
ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
|
|
263
|
-
changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
|
|
264
|
-
subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
|
|
265
|
-
sources: @sources || [],
|
|
266
|
-
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
267
|
-
incremental: config["incremental"], settings: agent_settings, typed_settings: typed_settings
|
|
268
|
-
)
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
# The settings keys typed on the command line: a typed empty operator
|
|
272
|
-
# list may clear a config value, so the generated commands keep it.
|
|
273
|
-
def typed_settings = options.keys & Reporter::Agent::Commands::SETTING_FLAGS.keys
|
|
274
|
-
|
|
275
|
-
# A whole-suite run records its scope as --all, so the generated rerun
|
|
276
|
-
# and replay commands do not default back to inference (which can exit
|
|
277
|
-
# 2 in a project with a spec-less source file).
|
|
278
|
-
def agent_settings
|
|
279
|
-
settings = options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
280
|
-
.merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
|
|
281
|
-
# The effective mode: named tests win over a configured whole-suite
|
|
282
|
-
# selection by precedence, so all is recorded only when no tests
|
|
283
|
-
# resolved - otherwise the follow-up commands would carry both --all
|
|
284
|
-
# and --test, which the CLI rejects.
|
|
285
|
-
settings[:all] = true if (config["all"] || config["test_selection"] == "all") && config["tests"].empty?
|
|
286
|
-
# CLI-only switches that change candidate availability or boot, so a
|
|
287
|
-
# printed rerun/replay must keep them (config-file settings like
|
|
288
|
-
# arid/hazard patterns, whole-file, require, rspec_args and rails ride
|
|
289
|
-
# along through --config or the default config path).
|
|
290
|
-
settings[:allow_hazardous] = true if options[:allow_hazardous]
|
|
291
|
-
settings[:allow_focus] = true if options[:allow_focus]
|
|
292
|
-
# An explicit false (--no-max-duration-ok over a config true) is a
|
|
293
|
-
# setting too: dropping it would silently re-enable the config value.
|
|
294
|
-
settings[:max_duration_ok] = options[:max_duration_ok] unless options[:max_duration_ok].nil?
|
|
295
|
-
settings
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def agent_mode
|
|
299
|
-
return "replay" if options[:id]
|
|
300
|
-
return "diff" if config["since"]
|
|
301
|
-
|
|
302
|
-
@sources&.any? ? "files" : "full"
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# Positional arguments name the files (or directories) to mutate. They
|
|
306
|
-
# replace the configured include and exclude patterns. Each must exist,
|
|
307
|
-
# so a typo is an error and not a run over zero subjects.
|
|
308
|
-
def use_explicit_sources
|
|
309
|
-
Selection::SourceScope.verify_no_specs!(@argv)
|
|
310
|
-
options[:tests]&.each { |test| require_existing_test(test) }
|
|
311
|
-
# --test may stand alone: the configured include (or the --since
|
|
312
|
-
# diff) names the sources. The files must still exist.
|
|
313
|
-
return if @argv.empty?
|
|
314
|
-
|
|
315
|
-
@sources = @argv.dup
|
|
316
|
-
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
317
|
-
options[:exclude] = []
|
|
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)
|
|
337
|
-
end
|
|
338
|
-
|
|
339
134
|
def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
|
|
340
135
|
|
|
341
|
-
def preview
|
|
342
|
-
raise UsageError, "--dry-run and --list-operators print text, not --agent JSON; use --format json" if agent?
|
|
343
|
-
raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
|
|
344
|
-
|
|
345
|
-
if options[:list_operators]
|
|
346
|
-
MutationSource::OperatorRegistry.list(@out, names: config["operators"], excluded: config["exclude_operators"])
|
|
347
|
-
return EXIT_OK
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
Preview.new(config, out: @out, err: @err, format: @format).call
|
|
351
|
-
EXIT_OK
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
def define_execution_options(opts)
|
|
355
|
-
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
356
|
-
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
357
|
-
add_run_budget_options(opts)
|
|
358
|
-
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
359
|
-
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
360
|
-
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
361
|
-
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
362
|
-
opts.on("--allow-hazardous", "Also run mutants inside destructive calls (rm_rf, system, ...)") do
|
|
363
|
-
set(:allow_hazardous, true)
|
|
364
|
-
end
|
|
365
|
-
opts.on("--fail-fast", "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
|
|
366
|
-
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
367
|
-
opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
|
|
368
|
-
set(:allow_focus, true)
|
|
369
|
-
end
|
|
370
|
-
opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
|
|
371
|
-
"mutated files") { set(:all, true) }
|
|
372
|
-
opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
|
|
373
|
-
"For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
|
|
374
|
-
set(:test_command, v)
|
|
375
|
-
end
|
|
376
|
-
opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
|
|
377
|
-
"--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
|
|
378
|
-
set(:guess_tests, v)
|
|
379
|
-
end
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
def define_matrix_option(opts)
|
|
383
|
-
opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
|
|
384
|
-
set(:matrix, true)
|
|
385
|
-
end
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
def define_output_options(opts)
|
|
389
|
-
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
390
|
-
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
391
|
-
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
392
|
-
opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
|
|
393
|
-
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
394
|
-
opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
|
|
395
|
-
"a crash prints its backtrace") do
|
|
396
|
-
@verbose = true
|
|
397
|
-
set(:verbose, true)
|
|
398
|
-
end
|
|
399
|
-
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
400
|
-
@color = v ? :always : :never
|
|
401
|
-
end
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
def define_explicit_file_options(opts)
|
|
405
|
-
opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
|
|
406
|
-
(options[:tests] ||= []) << v
|
|
407
|
-
end
|
|
408
|
-
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
409
|
-
set(:threshold, v)
|
|
410
|
-
end
|
|
411
|
-
Gates.define_options(opts) { |key, value| set(key, value) }
|
|
412
|
-
end
|
|
413
|
-
|
|
414
136
|
def add_subject(expr) = (options[:subjects] ||= []) << expr
|
|
415
137
|
|
|
416
138
|
def set(key, value) = (options[key] = value)
|