saboteur 0.8.0 → 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 +215 -0
- data/README.md +44 -6
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +116 -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/presets.rb +41 -1
- data/lib/saboteur/cli/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +6 -1
- 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/source_set.rb +0 -18
- data/lib/saboteur/cli.rb +29 -305
- data/lib/saboteur/config.rb +76 -7
- data/lib/saboteur/config_file.rb +12 -8
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +24 -5
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +6 -4
- data/lib/saboteur/doctor.rb +10 -0
- data/lib/saboteur/engine/baseline_guard.rb +118 -15
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +16 -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 +296 -21
- data/lib/saboteur/engine/diagnostics.rb +24 -7
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +4 -1
- data/lib/saboteur/engine/load_coverage.rb +72 -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 +11 -3
- 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 +10 -7
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +26 -5
- data/lib/saboteur/engine/scheduler.rb +33 -25
- data/lib/saboteur/engine/selection_announcement.rb +9 -3
- 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 +144 -64
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +186 -32
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +44 -11
- 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 +123 -1
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
- data/lib/saboteur/execution/marker_sweep.rb +2 -1
- data/lib/saboteur/execution/process_table.rb +7 -3
- data/lib/saboteur/execution/reevaluator.rb +7 -0
- data/lib/saboteur/execution/sandbox.rb +39 -20
- data/lib/saboteur/execution/seatbelt.rb +10 -3
- data/lib/saboteur/execution/supervisor.rb +42 -8
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
- 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 +145 -18
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +33 -21
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
- 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 +6 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +6 -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 +13 -0
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
- 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 +45 -5
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +150 -9
- 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 +9 -0
- metadata +48 -16
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Execution, cache and hazard flags for `saboteur run` (#334).
|
|
7
|
+
module ExecutionOptions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def define_execution_options(opts)
|
|
11
|
+
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
12
|
+
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
13
|
+
add_run_budget_options(opts)
|
|
14
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
15
|
+
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
16
|
+
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
17
|
+
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
18
|
+
opts.on("--allow-hazardous",
|
|
19
|
+
"Also run mutants inside destructive calls (rm_rf, system, ...)") { set(:allow_hazardous, true) }
|
|
20
|
+
opts.on("--fail-fast",
|
|
21
|
+
"Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
|
|
22
|
+
add_reuse_options(opts)
|
|
23
|
+
opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
|
|
24
|
+
set(:allow_focus, true)
|
|
25
|
+
end
|
|
26
|
+
opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
|
|
27
|
+
"mutated files") { set(:all, true) }
|
|
28
|
+
opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
|
|
29
|
+
"For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
|
|
30
|
+
set(:test_command, v)
|
|
31
|
+
end
|
|
32
|
+
opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
|
|
33
|
+
"--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
|
|
34
|
+
set(:guess_tests, v)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_reuse_options(opts)
|
|
39
|
+
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
40
|
+
opts.on("--resume DIR", "Keep verdicts in DIR and reuse them in a later run with the same inputs") do |v|
|
|
41
|
+
set(:resume, v)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -14,11 +14,9 @@ module Saboteur
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# The crash log lives with the other run state. A broken config (or a
|
|
17
|
-
# nil cache_dir) must not swallow the crash report itself
|
|
18
|
-
# back to no log.
|
|
17
|
+
# nil cache_dir) must not swallow the crash report itself: no log.
|
|
19
18
|
def crash_log_path
|
|
20
|
-
|
|
21
|
-
dir && File.join(dir, CrashReport::LOG_NAME)
|
|
19
|
+
config.path("cache_dir")&.then { |dir| File.join(dir, CrashReport::LOG_NAME) }
|
|
22
20
|
rescue StandardError
|
|
23
21
|
nil
|
|
24
22
|
end
|
|
@@ -34,7 +32,8 @@ module Saboteur
|
|
|
34
32
|
|
|
35
33
|
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
quality_failed = failed_quality_gate?(report)
|
|
36
|
+
gates.failed?(report, @err, record_cause: !quality_failed) || quality_failed ? EXIT_SURVIVORS : EXIT_OK
|
|
38
37
|
end
|
|
39
38
|
|
|
40
39
|
# A spent --max-duration budget exits 4 on its own, before the gates:
|
|
@@ -62,7 +61,7 @@ module Saboteur
|
|
|
62
61
|
def unverifiable_scope?(report)
|
|
63
62
|
if unverifiable_review?(report)
|
|
64
63
|
report.skipped_subjects.each do |subject, reason|
|
|
65
|
-
@err.puts(
|
|
64
|
+
@err.puts(subject.skipped_gate_sentence(reason, report.root))
|
|
66
65
|
end
|
|
67
66
|
return true
|
|
68
67
|
end
|
|
@@ -86,10 +85,9 @@ module Saboteur
|
|
|
86
85
|
def mostly_errored?(report)
|
|
87
86
|
return false unless report.majority_errored?
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
errors
|
|
91
|
-
|
|
92
|
-
"fix the errors and run again")
|
|
88
|
+
errored = report.executed.select { |result| result.status == :error }
|
|
89
|
+
@err.puts("saboteur: fix the errors and run again\n #{errored.size} of #{report.executed.size} " \
|
|
90
|
+
"mutants errored, so the run tested almost nothing#{ErrorCause.line(errored)}")
|
|
93
91
|
true
|
|
94
92
|
end
|
|
95
93
|
|
|
@@ -159,10 +157,10 @@ module Saboteur
|
|
|
159
157
|
def incomplete_under_threshold?(report)
|
|
160
158
|
return false if config["threshold"].nil?
|
|
161
159
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
160
|
+
statuses = gates.judges_errors? ? %i[resource_limit] : %i[error resource_limit]
|
|
161
|
+
problem = report.results.select { |result| statuses.include?(result.status) }
|
|
162
|
+
@err.puts(ErrorCause.incomplete(problem)) if problem.any?
|
|
163
|
+
problem.any?
|
|
166
164
|
end
|
|
167
165
|
end
|
|
168
166
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Explicit test selection and quality-gate flags (#334).
|
|
7
|
+
module ExplicitFileOptions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def define_explicit_file_options(opts)
|
|
11
|
+
opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
|
|
12
|
+
(options[:tests] ||= []) << v
|
|
13
|
+
end
|
|
14
|
+
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
15
|
+
set(:threshold, v)
|
|
16
|
+
end
|
|
17
|
+
Gates.define_options(opts) { |key, value| set(key, value) }
|
|
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
|
+
class CLI
|
|
6
|
+
# The source and test operands of `saboteur run`, checked against the
|
|
7
|
+
# file system before anything boots (#334). Extracted from CLI.
|
|
8
|
+
module ExplicitSources
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Positional arguments name the files (or directories) to mutate. They
|
|
12
|
+
# replace the configured include and exclude patterns. Each must exist,
|
|
13
|
+
# so a typo is an error and not a run over zero subjects.
|
|
14
|
+
def use_explicit_sources
|
|
15
|
+
Selection::SourceScope.verify_no_specs!(@argv)
|
|
16
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
17
|
+
# --test may stand alone: the configured include (or the --since
|
|
18
|
+
# diff) names the sources. The files must still exist.
|
|
19
|
+
return if @argv.empty?
|
|
20
|
+
|
|
21
|
+
@sources = @argv.dup
|
|
22
|
+
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
23
|
+
options[:exclude] = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def source_pattern(source)
|
|
27
|
+
return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
|
|
28
|
+
raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
|
|
29
|
+
raise UsageError, "source file not found: #{source}" unless File.file?(source)
|
|
30
|
+
|
|
31
|
+
glob_escape(source)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# include is a list of glob patterns; a named file must match only itself.
|
|
35
|
+
def glob_escape(path)
|
|
36
|
+
path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
|
|
40
|
+
def require_existing_test(test)
|
|
41
|
+
path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
|
|
42
|
+
raise UsageError, "test file not found: #{test}" unless File.exist?(path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Where a failed command speaks: stderr for people, and in agent mode the
|
|
7
|
+
# same failure as JSON on stdout (#334). Extracted from CLI.
|
|
8
|
+
module FailureOutput
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def usage_error(message = nil)
|
|
12
|
+
@err.puts(message) if message
|
|
13
|
+
@err.puts(USAGE)
|
|
14
|
+
agent_failure(message || USAGE, EXIT_USAGE)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def agent? = @format == "agent"
|
|
18
|
+
|
|
19
|
+
# In agent mode the failure is also on stdout, where the agent reads its answer.
|
|
20
|
+
def agent_failure(message, exit_code)
|
|
21
|
+
@out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
|
|
22
|
+
exit_code
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Error messages interpolate repository-controlled names, so each line is
|
|
26
|
+
# made safe before it is printed (a newline then "::error::" in a file
|
|
27
|
+
# name must not become a workflow command in the CI log).
|
|
28
|
+
def report_failure(error, exit_code)
|
|
29
|
+
message = Text.display_safe_lines(error.message)
|
|
30
|
+
@err.puts("saboteur: #{message}")
|
|
31
|
+
agent_failure(message, exit_code)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# `saboteur help` and the `--help` of every command (#334). Extracted
|
|
7
|
+
# from CLI by command.
|
|
8
|
+
module HelpCommand
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def help_requested? = @argv.intersect?(%w[--help -h])
|
|
12
|
+
|
|
13
|
+
def show_help(command_name)
|
|
14
|
+
help = Help.new(option_lines: option_lines)
|
|
15
|
+
text = command_name ? help.command(command_name) : help.overview
|
|
16
|
+
return @out.puts(text) || EXIT_OK if text
|
|
17
|
+
|
|
18
|
+
@err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
|
|
19
|
+
EXIT_USAGE
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The --matrix flag for `saboteur run` (#334).
|
|
7
|
+
module MatrixOption
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def define_matrix_option(opts)
|
|
11
|
+
opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
|
|
12
|
+
set(:matrix, true)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Report destinations and text output flags for `saboteur run` (#334).
|
|
7
|
+
module OutputOptions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def define_output_options(opts)
|
|
11
|
+
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
12
|
+
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
13
|
+
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
14
|
+
opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
|
|
15
|
+
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
16
|
+
opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
|
|
17
|
+
"a crash prints its backtrace") do
|
|
18
|
+
@verbose = true
|
|
19
|
+
set(:verbose, true)
|
|
20
|
+
end
|
|
21
|
+
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
22
|
+
@color = v ? :always : :never
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/saboteur/cli/presets.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Saboteur
|
|
|
14
14
|
--html .saboteur/audit-report.html]
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
|
+
# Preset flags that take a separate value token: the pairs a user
|
|
18
|
+
# flag can override in the banner (#666).
|
|
19
|
+
VALUE_FLAGS = %w[--baseline-passes --max-duration --since --operators --json --html].freeze
|
|
20
|
+
|
|
17
21
|
private
|
|
18
22
|
|
|
19
23
|
def preset_or_usage_error(command)
|
|
@@ -31,10 +35,46 @@ module Saboteur
|
|
|
31
35
|
# deprecated inference alias (#245).
|
|
32
36
|
explicit_scope = @argv.any? { |arg| arg == "--test" || arg.start_with?("--test=") || arg == "--guess-tests" }
|
|
33
37
|
flags.delete("--all") if explicit_scope
|
|
34
|
-
@err.puts("saboteur: preset '#{name}': #{flags.join(' ')} (explicit flags override)")
|
|
38
|
+
@err.puts("saboteur: preset '#{name}': #{preset_banner(flags).join(' ')} (explicit flags override)")
|
|
35
39
|
@argv = flags + @argv
|
|
36
40
|
run_command
|
|
37
41
|
end
|
|
42
|
+
|
|
43
|
+
# The banner is the reader's only record of the effective settings,
|
|
44
|
+
# so a preset flag the user also passed shows the user's spelling -
|
|
45
|
+
# the later occurrence wins (#666). Flags the user did not override
|
|
46
|
+
# print as the preset declares them.
|
|
47
|
+
def preset_banner(flags)
|
|
48
|
+
overrides = user_flag_overrides
|
|
49
|
+
banner = []
|
|
50
|
+
index = 0
|
|
51
|
+
while index < flags.size
|
|
52
|
+
if VALUE_FLAGS.include?(flags[index])
|
|
53
|
+
banner.concat(overrides.fetch(flags[index], [flags[index], flags[index + 1]]))
|
|
54
|
+
index += 2
|
|
55
|
+
else
|
|
56
|
+
banner << flags[index]
|
|
57
|
+
index += 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
banner
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Each long flag the user passed, mapped to its effective spelling:
|
|
64
|
+
# the last occurrence, verbatim, with its separate value token when
|
|
65
|
+
# the flag takes one.
|
|
66
|
+
def user_flag_overrides
|
|
67
|
+
overrides = {}
|
|
68
|
+
@argv.each_with_index do |arg, index|
|
|
69
|
+
name = arg[/\A--[^=]+/] or next
|
|
70
|
+
if arg.include?("=")
|
|
71
|
+
overrides[name] = [arg]
|
|
72
|
+
elsif VALUE_FLAGS.include?(name) && @argv[index + 1]
|
|
73
|
+
overrides[name] = [name, @argv[index + 1]]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
overrides
|
|
77
|
+
end
|
|
38
78
|
end
|
|
39
79
|
end
|
|
40
80
|
end
|
|
@@ -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,8 +13,13 @@ 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
|
-
opts.on("--max-duration DURATION", "Wall-clock budget for the whole run, e.g. 10m; exit 4 when spent"
|
|
20
|
+
opts.on("--max-duration DURATION", "Wall-clock budget for the whole run, e.g. 10m; exit 4 when spent - " \
|
|
21
|
+
"the clock starts at engine entry, so boot, baselines and the " \
|
|
22
|
+
"coverage map spend it too") do |v|
|
|
18
23
|
set(:max_duration, parse_duration(v))
|
|
19
24
|
end
|
|
20
25
|
opts.on("--[no-]max-duration-ok", "An exhausted --max-duration budget does not fail the run") do |v|
|
|
@@ -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
|
|
@@ -18,24 +18,6 @@ module Saboteur
|
|
|
18
18
|
raise UsageError, empty_source_set_message
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
# A --since ref whose resolved range diffs empty yields 0 subjects
|
|
22
|
-
# and a green run over nothing: `saboteur pr <merge_sha>` on a
|
|
23
|
-
# checkout AT the merge diffs the merge against itself (#643).
|
|
24
|
-
# Refuse like a typo'd ref: exit 2, name the base and the range.
|
|
25
|
-
def refuse_empty_since_range
|
|
26
|
-
ref = config["since"]
|
|
27
|
-
return unless ref
|
|
28
|
-
|
|
29
|
-
paths = Selection::GitDiff.changed_paths(ref, root: config.root)
|
|
30
|
-
return unless paths.values.all?(&:empty?)
|
|
31
|
-
|
|
32
|
-
base = Selection::GitDiff.merge_base(ref, root: config.root)
|
|
33
|
-
raise UsageError, "--since #{ref}: the resolved range #{base}..the working tree has no changes - " \
|
|
34
|
-
"the run would pass with 0 subjects. A checkout AT the ref (the CI merge-sha " \
|
|
35
|
-
"checkout) diffs it against itself: fetch the merge's parent, or point --since " \
|
|
36
|
-
"at the base of the change under test"
|
|
37
|
-
end
|
|
38
|
-
|
|
39
21
|
def empty_source_set_message
|
|
40
22
|
include_patterns = config["include"]
|
|
41
23
|
matched = include_patterns.flat_map { |pattern| Dir.glob(pattern, base: config.root) }
|