saboteur 0.7.0 → 0.8.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 +657 -0
- data/README.md +198 -1077
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +6 -2
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +53 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +71 -54
- data/lib/saboteur/config.rb +98 -5
- data/lib/saboteur/config_file.rb +8 -0
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +9 -0
- data/lib/saboteur/engine/baseline_guard.rb +62 -14
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +19 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +32 -10
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +35 -0
- data/lib/saboteur/engine/load_coverage.rb +45 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +107 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +27 -0
- data/lib/saboteur/engine/sandbox_policy.rb +38 -0
- data/lib/saboteur/engine/scheduler.rb +109 -3
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +295 -20
- data/lib/saboteur/engine/test_guess.rb +315 -25
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +100 -127
- data/lib/saboteur/execution/external_runner.rb +471 -0
- data/lib/saboteur/execution/marker_sweep.rb +79 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +12 -0
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +15 -2
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +27 -10
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +135 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +26 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +120 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +40 -7
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +105 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +13 -0
- metadata +35 -2
|
@@ -28,6 +28,8 @@ module Saboteur
|
|
|
28
28
|
|
|
29
29
|
return EXIT_TOOL if untested_partial?(report)
|
|
30
30
|
|
|
31
|
+
return EXIT_TOOL if unverifiable_scope?(report)
|
|
32
|
+
|
|
31
33
|
return EXIT_TOOL if mostly_errored?(report)
|
|
32
34
|
|
|
33
35
|
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
@@ -53,6 +55,32 @@ module Saboteur
|
|
|
53
55
|
true
|
|
54
56
|
end
|
|
55
57
|
|
|
58
|
+
# A run that verified none of the change under review - every
|
|
59
|
+
# subject skipped (refinements, no coverage, ..., #574) or every
|
|
60
|
+
# mutant unreachable (a suite re-loading mutated files, #597) -
|
|
61
|
+
# must not exit 0 with score n/a and green-light an untested change.
|
|
62
|
+
def unverifiable_scope?(report)
|
|
63
|
+
if unverifiable_review?(report)
|
|
64
|
+
report.skipped_subjects.each do |subject, reason|
|
|
65
|
+
@err.puts("saboteur: #{subject.id} was skipped (#{reason}), so the change under test was not verified")
|
|
66
|
+
end
|
|
67
|
+
return true
|
|
68
|
+
end
|
|
69
|
+
return false unless unverifiable_unreachable?(report)
|
|
70
|
+
|
|
71
|
+
@err.puts("saboteur: all mutants ran unreachable, so the change under test was not verified")
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unverifiable_review?(report)
|
|
76
|
+
report.review? && report.subjects.any? &&
|
|
77
|
+
report.subjects.all? { |subject| report.skipped_subjects.key?(subject) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unverifiable_unreachable?(report)
|
|
81
|
+
report.executed.any? && report.executed.all? { |result| result.status == :unreachable }
|
|
82
|
+
end
|
|
83
|
+
|
|
56
84
|
def gates = (@gates ||= Gates.new(config))
|
|
57
85
|
|
|
58
86
|
def mostly_errored?(report)
|
|
@@ -89,8 +117,11 @@ module Saboteur
|
|
|
89
117
|
else
|
|
90
118
|
if report.majority_errored?
|
|
91
119
|
"errorMajority"
|
|
120
|
+
elsif unverifiable_review?(report) || unverifiable_unreachable?(report) ||
|
|
121
|
+
(report.early_stop && report.score_breakdown.tested.zero?)
|
|
122
|
+
"incomplete"
|
|
92
123
|
else
|
|
93
|
-
|
|
124
|
+
"incompleteUnderThreshold"
|
|
94
125
|
end
|
|
95
126
|
end
|
|
96
127
|
end
|
|
@@ -9,7 +9,8 @@ module Saboteur
|
|
|
9
9
|
def option_lines
|
|
10
10
|
run = parser.summarize
|
|
11
11
|
{ "run" => run, "pr" => run, "audit" => run, "doctor" => doctor_parser.summarize,
|
|
12
|
-
"baseline" => baseline_parser.summarize
|
|
12
|
+
"baseline" => baseline_parser.summarize, "badge" => badge_parser.summarize,
|
|
13
|
+
"trend" => trend_parser.summarize }
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../reporter/history_reader"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class CLI
|
|
8
|
+
# `saboteur badge` and `saboteur trend` (#474): a local README badge and
|
|
9
|
+
# a trend read-out over the opt-in score history, no hosted service.
|
|
10
|
+
module HistoryCommand
|
|
11
|
+
DEFAULT_HIGH = 80.0
|
|
12
|
+
DEFAULT_LOW = 60.0
|
|
13
|
+
|
|
14
|
+
def badge_parser
|
|
15
|
+
OptionParser.new do |opts|
|
|
16
|
+
opts.banner = "Usage: saboteur badge [--history PATH] [--output PATH] [--mark-partial] " \
|
|
17
|
+
"[--high PERCENT] [--low PERCENT]"
|
|
18
|
+
opts.on("--history PATH", "Score history JSONL (default: c.score_history)") { |v| @history_path = v }
|
|
19
|
+
opts.on("--output PATH", "Badge JSON destination (default: stdout)") { |v| @output = v }
|
|
20
|
+
opts.on("--mark-partial", "Badge an incomplete run, labeled as partial") { @mark_partial = true }
|
|
21
|
+
opts.on("--high PERCENT", Float, "brightgreen at or above (default #{DEFAULT_HIGH.to_i})") { |v| @high = v }
|
|
22
|
+
opts.on("--low PERCENT", Float, "yellow at or above (default #{DEFAULT_LOW.to_i})") { |v| @low = v }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def trend_parser
|
|
27
|
+
OptionParser.new do |opts|
|
|
28
|
+
opts.banner = "Usage: saboteur trend [--history PATH]"
|
|
29
|
+
opts.on("--history PATH", "Score history JSONL (default: c.score_history)") { |v| @history_path = v }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def history_command(command) = command == "badge" ? badge : trend
|
|
36
|
+
|
|
37
|
+
def badge
|
|
38
|
+
badge_parser.parse!(@argv)
|
|
39
|
+
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
40
|
+
|
|
41
|
+
write_badge(badge_payload(read_history.last))
|
|
42
|
+
EXIT_OK
|
|
43
|
+
rescue ConfigError, Error, SystemCallError => e
|
|
44
|
+
history_failure(e)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def trend
|
|
48
|
+
trend_parser.parse!(@argv)
|
|
49
|
+
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
50
|
+
|
|
51
|
+
print_trend(latest_pair)
|
|
52
|
+
EXIT_OK
|
|
53
|
+
rescue ConfigError, Error, SystemCallError => e
|
|
54
|
+
history_failure(e)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def history_failure(error)
|
|
58
|
+
@err.puts("saboteur: #{error.message}")
|
|
59
|
+
error.is_a?(ConfigError) ? EXIT_USAGE : EXIT_TOOL
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def latest_pair
|
|
63
|
+
records = read_history
|
|
64
|
+
current = records.last
|
|
65
|
+
[records[0...-1].reverse.find { |record| Reporter::HistoryReader.comparable?(record, current) }, current]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def read_history
|
|
69
|
+
records = Reporter::HistoryReader.read(history_path)
|
|
70
|
+
if records.empty?
|
|
71
|
+
raise Error,
|
|
72
|
+
"score history is empty - record it first by running saboteur with c.score_history set"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
records
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def history_path
|
|
79
|
+
@history_path ||= config.path("score_history")
|
|
80
|
+
@history_path or raise UsageError, "no score history: pass --history PATH or set c.score_history"
|
|
81
|
+
rescue ConfigError
|
|
82
|
+
raise UsageError, "no score history: pass --history PATH or set c.score_history"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def badge_payload(record)
|
|
86
|
+
partial = record["incomplete"].any?
|
|
87
|
+
if partial && !@mark_partial
|
|
88
|
+
raise Error, "the latest score history record is an incomplete run " \
|
|
89
|
+
"(#{record['incomplete'].map(&:inspect).join(', ')}) - " \
|
|
90
|
+
"badge evidence needs a complete run, or pass --mark-partial to label it"
|
|
91
|
+
end
|
|
92
|
+
score = record["coveredScore"]
|
|
93
|
+
raise Error, "the latest score history record has no tested mutants" if score.nil?
|
|
94
|
+
|
|
95
|
+
{ schemaVersion: 1, label: partial ? "mutation score (partial)" : "mutation score",
|
|
96
|
+
message: format("%.1f%%", score * 100), color: badge_color(score) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write_badge(payload)
|
|
100
|
+
json = "#{JSON.generate(payload)}\n"
|
|
101
|
+
return @out.print(json) unless @output
|
|
102
|
+
|
|
103
|
+
File.write(@output, json)
|
|
104
|
+
@out.puts(@output)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def badge_color(score)
|
|
108
|
+
percent = score * 100
|
|
109
|
+
return "brightgreen" if percent >= (@high || DEFAULT_HIGH)
|
|
110
|
+
return "yellow" if percent >= (@low || DEFAULT_LOW)
|
|
111
|
+
|
|
112
|
+
"red"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def print_trend((previous, current))
|
|
116
|
+
([previous].compact + [current]).each { |record| @out.puts(trend_line(record)) }
|
|
117
|
+
unless previous && previous["coveredScore"] && current["coveredScore"]
|
|
118
|
+
return @out.puts("score history: no comparable earlier record (different selection/inventory, " \
|
|
119
|
+
"operator policy or an incomplete run)")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
delta = (current["coveredScore"] - previous["coveredScore"]) * 100
|
|
123
|
+
@out.puts(format("covered score change %<delta>+.2f percentage points since %<since>s " \
|
|
124
|
+
"(same configured selection)", delta: delta, since: previous["date"]))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def trend_line(record)
|
|
128
|
+
suffix = record["incomplete"].empty? ? "" : " (incomplete)"
|
|
129
|
+
score = record["coveredScore"]
|
|
130
|
+
"#{record['date']} #{record['commit']&.slice(0, 8) || '-'} " \
|
|
131
|
+
"#{score.nil? ? 'no tested mutants' : format('%.1f%%', score * 100)}#{suffix}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/saboteur/cli/presets.rb
CHANGED
|
@@ -4,18 +4,22 @@
|
|
|
4
4
|
module Saboteur
|
|
5
5
|
class CLI
|
|
6
6
|
# Opinionated bundles (#244): pr is the fast changed-lines loop (one
|
|
7
|
-
# baseline check, a 10-minute budget that
|
|
7
|
+
# baseline check, a 10-minute budget that fails an incomplete run),
|
|
8
8
|
# audit is the whole project with both reports. Preset flags go first,
|
|
9
9
|
# so an explicit flag after them wins on the usual last-flag rule.
|
|
10
10
|
module Presets
|
|
11
11
|
PRESETS = {
|
|
12
|
-
"pr" => %w[--baseline-passes 1 --max-duration 10m
|
|
12
|
+
"pr" => %w[--baseline-passes 1 --max-duration 10m],
|
|
13
13
|
"audit" => %w[--all --operators all --baseline-passes 2 --json .saboteur/audit-report.json
|
|
14
14
|
--html .saboteur/audit-report.html]
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
|
+
def preset_or_usage_error(command)
|
|
20
|
+
PRESETS.key?(command) ? preset(command) : usage_error
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
def preset(name)
|
|
20
24
|
flags = PRESETS.fetch(name).dup
|
|
21
25
|
if name == "pr"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The run's subject-selection flags: operator sets, single-mutant replay,
|
|
7
|
+
# --since scoping, --dry-run, the flaky-check confirmation rerun (#476)
|
|
8
|
+
# and explicit --subject filters. Grouped so the CLI stays under the
|
|
9
|
+
# house size caps.
|
|
10
|
+
module SelectionOptions
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def define_selection_options(opts)
|
|
14
|
+
opts.on("--operators NAMES", Array, "Choose operator names (default: all)") do |v|
|
|
15
|
+
set(:operators, v)
|
|
16
|
+
end
|
|
17
|
+
opts.on("--exclude-operators NAMES", Array, "Exclude operator names from the selected set") do |v|
|
|
18
|
+
set(:exclude_operators, v)
|
|
19
|
+
end
|
|
20
|
+
opts.on("--list-operators", "List operator names and descriptions") { set(:list_operators, true) }
|
|
21
|
+
opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
|
|
22
|
+
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
23
|
+
opts.on("--shard K/N", "Run one of N deterministic candidate shards (for example 1/4)") { |v| set(:shard, v) }
|
|
24
|
+
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
25
|
+
flaky_desc = "Confirm a fresh verdict (survivors, decisive) with one reordered rerun"
|
|
26
|
+
opts.on("--flaky-check MODE", %w[off survivors decisive], flaky_desc) { |v| set(:flaky_check, v) }
|
|
27
|
+
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Validates the deterministic candidate-shard selection before the target boots.
|
|
7
|
+
module ShardOptions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def validate_shard_options
|
|
11
|
+
raise UsageError, "--id cannot be combined with --shard" if options[:id] && config["shard"]
|
|
12
|
+
|
|
13
|
+
Engine::Shard.parse(config["shard"]) if config["shard"]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The effective source set a run will mutate, refused when it is empty.
|
|
7
|
+
module SourceSet
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# The Rails-shaped footgun (#447): the default include targets plain
|
|
11
|
+
# gems, so a first run on an app/ tree selects nothing and would exit 0
|
|
12
|
+
# over 0 subjects - a green gate that mutates nothing, every CI run.
|
|
13
|
+
# Refuse like a typo'd source: exit 2, name the stale pattern and the
|
|
14
|
+
# way out. --dry-run still previews: it gates nothing.
|
|
15
|
+
def refuse_empty_source_set
|
|
16
|
+
return unless config.included_files.empty?
|
|
17
|
+
|
|
18
|
+
raise UsageError, empty_source_set_message
|
|
19
|
+
end
|
|
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
|
+
def empty_source_set_message
|
|
40
|
+
include_patterns = config["include"]
|
|
41
|
+
matched = include_patterns.flat_map { |pattern| Dir.glob(pattern, base: config.root) }
|
|
42
|
+
.select { |file| file.end_with?(".rb") }
|
|
43
|
+
cause = if matched.empty?
|
|
44
|
+
"include: #{include_patterns.join(', ')} selects no Ruby file"
|
|
45
|
+
else
|
|
46
|
+
"exclude: #{config['exclude'].join(', ')} removes every included file"
|
|
47
|
+
end
|
|
48
|
+
"#{cause} - the run would pass with 0 subjects. Point include at your code " \
|
|
49
|
+
"(Rails apps: bundle exec rails generate saboteur:install writes the config) or fix the patterns"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class CLI
|
|
8
|
+
# saboteur survivors <report.json> (#475): a read-only, line-oriented
|
|
9
|
+
# terminal browser for the Stryker JSON report. It never boots the
|
|
10
|
+
# project or reruns tests; it lists survivors, filters them, and for
|
|
11
|
+
# one digest prints the diff, the exact replay command and the
|
|
12
|
+
# ignore-file template. It never edits the ignore file.
|
|
13
|
+
module SurvivorsCommand
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def survivors_command
|
|
17
|
+
survivors_command_inner
|
|
18
|
+
rescue UsageError => e
|
|
19
|
+
@err.puts("saboteur: #{e.message}")
|
|
20
|
+
EXIT_USAGE
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def survivors_command_inner
|
|
24
|
+
parser = OptionParser.new do |opts|
|
|
25
|
+
opts.on("--ranked", "Review-priority order: the report's ranked findings first")
|
|
26
|
+
opts.on("--file SUBSTR", "Only survivors whose path contains SUBSTR")
|
|
27
|
+
opts.on("--operator NAME", "Only survivors of operator NAME")
|
|
28
|
+
opts.on("--triage LABEL", "Only survivors classified LABEL (for example missing_test)")
|
|
29
|
+
opts.on("--show DIGEST", "Diff, replay command and ignore template for one survivor")
|
|
30
|
+
end
|
|
31
|
+
options = {}
|
|
32
|
+
args = parser.parse!(@argv, into: options)
|
|
33
|
+
path = args.shift or raise UsageError,
|
|
34
|
+
"survivors expects a report path: saboteur survivors .saboteur/report.json"
|
|
35
|
+
raise UsageError, "unexpected arguments: #{args.join(' ')}" if args.any?
|
|
36
|
+
|
|
37
|
+
report = load_survivor_report(path)
|
|
38
|
+
browser = SurvivorsBrowser.new(report)
|
|
39
|
+
if options[:show]
|
|
40
|
+
@out.puts(browser.show(options[:show]))
|
|
41
|
+
else
|
|
42
|
+
@out.puts(browser.list(**options.slice(:ranked, :file, :operator, :triage)))
|
|
43
|
+
end
|
|
44
|
+
EXIT_OK
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def load_survivor_report(path)
|
|
48
|
+
raise UsageError, "report not found: #{path}" unless File.file?(path)
|
|
49
|
+
|
|
50
|
+
JSON.parse(File.read(path))
|
|
51
|
+
rescue JSON::ParserError => e
|
|
52
|
+
raise UsageError, "#{path} is not a readable saboteur report: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reads the report's files section (the complete mutant list), not the
|
|
57
|
+
# capped findings, so survivors beyond the findings limit still list.
|
|
58
|
+
class SurvivorsBrowser
|
|
59
|
+
def initialize(report)
|
|
60
|
+
@report = report
|
|
61
|
+
@findings = report.dig("saboteur", "findings") || {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def list(ranked: false, file: nil, operator: nil, triage: nil)
|
|
65
|
+
survivors = filtered(file, operator, triage)
|
|
66
|
+
survivors = ranked_order(survivors) if ranked
|
|
67
|
+
return "no survivors#{filters_note(file, operator, triage)}" if survivors.empty?
|
|
68
|
+
|
|
69
|
+
[header(survivors.size, file, operator, triage), *rows(survivors)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def show(digest)
|
|
73
|
+
survivor = all_survivors.find { |s| s[:digest].start_with?(digest) } ||
|
|
74
|
+
raise(UsageError, "no survivor with digest #{digest} in this report")
|
|
75
|
+
finding = findings_by_digest[survivor[:digest]] || {}
|
|
76
|
+
subject = finding["subject"]
|
|
77
|
+
[
|
|
78
|
+
"#{survivor[:path]}:#{survivor[:line]} #{survivor[:operator]} #{survivor[:digest]}",
|
|
79
|
+
"",
|
|
80
|
+
"- #{finding['original'] || original_line(survivor)}",
|
|
81
|
+
"+ #{finding['replacement'] || survivor[:replacement]}",
|
|
82
|
+
"",
|
|
83
|
+
"Replay exactly this mutant:",
|
|
84
|
+
" bundle exec saboteur run --id #{survivor[:digest]}",
|
|
85
|
+
"",
|
|
86
|
+
"Ignore-file entry (config/saboteur_ignore.rb):",
|
|
87
|
+
*ignore_entry(survivor, subject)
|
|
88
|
+
].join("\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def filtered(file, operator, triage)
|
|
94
|
+
survivors = all_survivors
|
|
95
|
+
survivors = survivors.select { |s| s[:path].include?(file) } if file
|
|
96
|
+
survivors = survivors.select { |s| s[:operator] == operator } if operator
|
|
97
|
+
triage ? survivors.select { |s| s[:classification] == triage } : survivors
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def header(size, file, operator, triage)
|
|
101
|
+
text = "#{size} survivor#{'s' unless size == 1}#{filters_note(file, operator, triage)}"
|
|
102
|
+
truncated = @findings["survivorsTruncated"].to_i
|
|
103
|
+
return text unless truncated.positive?
|
|
104
|
+
|
|
105
|
+
"#{text} (the report's ranked findings name only the first #{@findings['limit'] || 10})"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def rows(survivors)
|
|
109
|
+
survivors.each_with_index.map do |s, i|
|
|
110
|
+
format("%3<i>d. %<path>s:%<line>d %-28<operator>s %<digest>s %<classification>s",
|
|
111
|
+
i: i + 1, path: s[:path], line: s[:line], operator: s[:operator], digest: s[:digest],
|
|
112
|
+
classification: s[:classification] || "-")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ignore_entry(survivor, subject)
|
|
117
|
+
note = subject ? subject.inspect : '"Klass#meth" # TODO: capped findings omit this digest - fill it in'
|
|
118
|
+
[%( i.mutation "#{survivor[:digest]}",), " subject: #{note},", %( reason: "...")]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def all_survivors
|
|
122
|
+
@all_survivors ||= begin
|
|
123
|
+
survivors = @report.fetch("files", {}).flat_map do |path, entry|
|
|
124
|
+
entry.fetch("mutants", []).select { |m| m["status"] == "Survived" }.map do |m|
|
|
125
|
+
{ path: path, line: m.dig("location", "start", "line").to_i, operator: m["mutatorName"],
|
|
126
|
+
digest: m["id"], replacement: m["replacement"], classification: m["classification"],
|
|
127
|
+
covered: m["coveredBy"] || [], source: entry["source"] }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
survivors.sort_by { |s| [s[:path], s[:line], s[:digest]] }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ranked_order(survivors)
|
|
135
|
+
rank = @findings.fetch("survivors", []).each_with_index.to_h { |f, i| [f["digest"], i] }
|
|
136
|
+
survivors.sort_by { |s| [rank[s[:digest]] || Float::INFINITY, -s[:covered].size, s[:path], s[:line], s[:digest]] }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def findings_by_digest
|
|
140
|
+
@findings_by_digest ||=
|
|
141
|
+
(@findings.fetch("survivors", []) + @findings.fetch("accepted", [])).to_h { |f| [f["digest"], f] }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def original_line(survivor)
|
|
145
|
+
survivor[:source].to_s.lines[survivor[:line] - 1].to_s.strip
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def filters_note(file, operator, triage)
|
|
149
|
+
parts = []
|
|
150
|
+
parts << "file~#{file}" if file
|
|
151
|
+
parts << "operator=#{operator}" if operator
|
|
152
|
+
parts << "triage=#{triage}" if triage
|
|
153
|
+
parts.empty? ? "" : " (#{parts.join(', ')})"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|