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
|
@@ -32,8 +32,16 @@ module Saboteur
|
|
|
32
32
|
|
|
33
33
|
# Where a verdict comes from: the exact selection and the state of the working
|
|
34
34
|
# source before the run started. subjects, tests and sources are arrays.
|
|
35
|
+
# typed_settings: the settings keys typed on the command line, so the
|
|
36
|
+
# generated commands can tell a typed empty list (which may clear a
|
|
37
|
+
# config-file value) from one the config itself supplies.
|
|
35
38
|
Provenance = Data.define(:ref, :seed, :head, :working_tree, :changed_files, :mode, :json_report, :subjects,
|
|
36
|
-
:tests, :sources, :config_path, :digest, :cache, :incremental, :settings
|
|
39
|
+
:tests, :sources, :config_path, :digest, :cache, :incremental, :settings,
|
|
40
|
+
:typed_settings) do
|
|
41
|
+
def initialize(typed_settings: [], **fields)
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
end
|
|
37
45
|
|
|
38
46
|
class << self
|
|
39
47
|
def call(report, provenance)
|
|
@@ -19,7 +19,8 @@ module Saboteur
|
|
|
19
19
|
SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
|
|
20
20
|
timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html",
|
|
21
21
|
operators: "--operators", exclude_operators: "--exclude-operators",
|
|
22
|
-
baseline_passes: "--baseline-passes", max_duration: "--max-duration"
|
|
22
|
+
baseline_passes: "--baseline-passes", max_duration: "--max-duration",
|
|
23
|
+
shard: "--shard" }.freeze
|
|
23
24
|
|
|
24
25
|
# CLI-only boolean switches recorded in settings, mapped by value, so
|
|
25
26
|
# an explicit false (--no-max-duration-ok over a config true) survives
|
|
@@ -70,11 +71,17 @@ module Saboteur
|
|
|
70
71
|
tests.flat_map { |test| ["--test", test] }
|
|
71
72
|
end
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
# An empty list nobody typed came from the config file or its
|
|
75
|
+
# default, which the command reads again, so its flag is left out
|
|
76
|
+
# (#317). A typed empty list stays: it may clear a config value.
|
|
77
|
+
def setting_words(provenance, replay: false)
|
|
74
78
|
SETTING_FLAGS.flat_map do |key, flag|
|
|
79
|
+
next [] if replay && key == :shard
|
|
75
80
|
next [] unless provenance.settings.key?(key)
|
|
76
81
|
|
|
77
82
|
value = provenance.settings[key]
|
|
83
|
+
next [] if value == [] && !provenance.typed_settings.include?(key)
|
|
84
|
+
|
|
78
85
|
[flag, value.is_a?(Array) ? value.join(",") : value.to_s]
|
|
79
86
|
end
|
|
80
87
|
end
|
|
@@ -88,7 +95,7 @@ module Saboteur
|
|
|
88
95
|
provenance.subjects.each { |subject| words.push("--subject", subject) }
|
|
89
96
|
words << "--all" if provenance.settings[:all] && provenance.tests.empty?
|
|
90
97
|
words.push(*test_words(provenance.tests))
|
|
91
|
-
words.concat(setting_words(provenance))
|
|
98
|
+
words.concat(setting_words(provenance, replay: replay))
|
|
92
99
|
words.concat(mode_words(provenance))
|
|
93
100
|
words.push("--id", provenance.digest) if provenance.digest && !replay
|
|
94
101
|
words
|
|
@@ -14,6 +14,7 @@ module Saboteur
|
|
|
14
14
|
class << self
|
|
15
15
|
def reasons(report)
|
|
16
16
|
reasons = []
|
|
17
|
+
shard_reason(reasons, report)
|
|
17
18
|
add(reasons, "no_coverage", skipped_for_coverage(report), "subjects or mutants had no covering test")
|
|
18
19
|
load_only = report.counts[:ran_at_load] + report.skipped_subjects.values.count do |reason|
|
|
19
20
|
reason == Selection::CoverageMap::RAN_AT_LOAD
|
|
@@ -35,6 +36,16 @@ module Saboteur
|
|
|
35
36
|
|
|
36
37
|
private
|
|
37
38
|
|
|
39
|
+
# A shard always runs a subset of the planned inventory by contract, even
|
|
40
|
+
# when this shard happened to select every planned mutant (count 0).
|
|
41
|
+
def shard_reason(reasons, report)
|
|
42
|
+
shard = report.shard
|
|
43
|
+
return unless shard
|
|
44
|
+
|
|
45
|
+
reasons << { reason: "shard", count: shard.planned_count - shard.selected_count,
|
|
46
|
+
meaning: "planned mutants assigned to other shards" }
|
|
47
|
+
end
|
|
48
|
+
|
|
38
49
|
# Source the selection could not read at all: its subjects never
|
|
39
50
|
# entered the run, so a clean or complete verdict would be wrong.
|
|
40
51
|
def discovery_failures(reasons, report)
|
|
@@ -26,10 +26,11 @@ module Saboteur
|
|
|
26
26
|
counts: counts(report), score: score(report),
|
|
27
27
|
scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
|
|
28
28
|
incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
|
|
29
|
-
findings: findings(report, shown, snippet_limit)
|
|
29
|
+
findings: findings(report, shown, snippet_limit)
|
|
30
|
+
}.merge(report.shard ? { shard: report.shard.to_h } : {}).merge(
|
|
30
31
|
warnings: report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] },
|
|
31
32
|
next: next_steps(report, provenance)
|
|
32
|
-
|
|
33
|
+
)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
private
|
|
@@ -95,7 +96,7 @@ module Saboteur
|
|
|
95
96
|
|
|
96
97
|
def shown_findings(listed, shown, snippet_limit)
|
|
97
98
|
listed.first(shown).map do |finding|
|
|
98
|
-
finding.slice(:digest, :line, :endLine, :operator, :coarse)
|
|
99
|
+
finding.slice(:digest, :line, :endLine, :operator, :coarse, :classification)
|
|
99
100
|
.merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
|
|
100
101
|
original: clip(finding[:original], snippet_limit),
|
|
101
102
|
replacement: clip(finding[:replacement], snippet_limit))
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "survivor_ranking"
|
|
5
|
+
require_relative "survivor_classification"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
module Reporter
|
|
@@ -59,8 +60,10 @@ module Saboteur
|
|
|
59
60
|
original: joined_texts(result) { |edit| original_text(result, edit) },
|
|
60
61
|
replacement: joined_texts(result, &:replacement),
|
|
61
62
|
edits: entries,
|
|
62
|
-
coarse: result.mutation.coarse
|
|
63
|
-
|
|
63
|
+
coarse: result.mutation.coarse,
|
|
64
|
+
# Why the survivor is worth a look, as a label only (#192).
|
|
65
|
+
classification: SurvivorClassification.label(result)
|
|
66
|
+
}.compact
|
|
64
67
|
entry = with_changed_lines(report, result, entry)
|
|
65
68
|
result.ignored ? entry.merge(reason: result.ignored) : entry
|
|
66
69
|
end
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
module Saboteur
|
|
5
5
|
module Reporter
|
|
6
6
|
# v1 stays readable, but v2 records require versioned operator evidence.
|
|
7
|
+
# A policy stamped with an older POLICY_VERSION is shape-checked only:
|
|
8
|
+
# the operator set has moved on, so it never recomputes equal - and it
|
|
9
|
+
# never compares equal to a current run's policy either, which is what
|
|
10
|
+
# keeps trend from mixing operator sets (#135).
|
|
7
11
|
module HistoryPolicy
|
|
8
12
|
def validate_header(record, fields)
|
|
9
13
|
version = record.is_a?(Hash) && record["version"]
|
|
@@ -18,6 +22,11 @@ module Saboteur
|
|
|
18
22
|
raise Error, "score history contains invalid operator policy" unless value.is_a?(Hash)
|
|
19
23
|
|
|
20
24
|
fields(value, %w[version selected excluded effective])
|
|
25
|
+
policy_version = value["version"]
|
|
26
|
+
unless policy_version.is_a?(Integer) &&
|
|
27
|
+
policy_version.between?(1, MutationSource::OperatorRegistry::POLICY_VERSION)
|
|
28
|
+
raise Error, "score history contains invalid operator policy"
|
|
29
|
+
end
|
|
21
30
|
raise Error, "score history contains invalid operator policy" if value["effective"].empty?
|
|
22
31
|
|
|
23
32
|
lists = value.values_at("selected", "excluded", "effective")
|
|
@@ -25,6 +34,8 @@ module Saboteur
|
|
|
25
34
|
raise Error, "score history contains invalid operator policy"
|
|
26
35
|
end
|
|
27
36
|
|
|
37
|
+
return if policy_version != MutationSource::OperatorRegistry::POLICY_VERSION
|
|
38
|
+
|
|
28
39
|
expected = MutationSource::OperatorRegistry.policy(value["selected"], value["excluded"])
|
|
29
40
|
raise Error, "score history contains invalid operator policy" unless value == expected
|
|
30
41
|
rescue ConfigError, NoMethodError, TypeError
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "score_history"
|
|
5
|
+
require_relative "history_record"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module Reporter
|
|
9
|
+
# Read side of the append-only score history for `saboteur badge` and
|
|
10
|
+
# `saboteur trend` (#474). Every record is re-validated on read and the
|
|
11
|
+
# file discipline mirrors ScoreHistory: regular file, no symlink,
|
|
12
|
+
# byte-capped lines, no repair of imported evidence.
|
|
13
|
+
module HistoryReader
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def read(path)
|
|
17
|
+
records = []
|
|
18
|
+
File.open(path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) do |file|
|
|
19
|
+
raise Error, "score history must be a regular file" unless file.stat.file?
|
|
20
|
+
|
|
21
|
+
file.flock(File::LOCK_SH)
|
|
22
|
+
limit = ScoreHistory::MAX_RECORD_BYTES
|
|
23
|
+
while (line = file.gets(limit + 1))
|
|
24
|
+
raise Error, "score history record exceeds byte limit" if line.bytesize > limit
|
|
25
|
+
raise Error, "score history contains a truncated record" unless line.end_with?("\n")
|
|
26
|
+
|
|
27
|
+
records << HistoryRecord.parse(line)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
records
|
|
31
|
+
rescue SystemCallError, IOError => e
|
|
32
|
+
raise Error, "score history cannot be read: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The ScoreHistory change rules: same record version, selection,
|
|
36
|
+
# inventory and operator policy (a null policy never compares), both
|
|
37
|
+
# runs complete.
|
|
38
|
+
def comparable?(previous, current)
|
|
39
|
+
previous["version"] == current["version"] &&
|
|
40
|
+
previous["selection"] == current["selection"] &&
|
|
41
|
+
previous["inventory"] == current["inventory"] &&
|
|
42
|
+
previous["incomplete"].empty? && current["incomplete"].empty? &&
|
|
43
|
+
!previous["operatorPolicy"].nil? && previous["operatorPolicy"] == current["operatorPolicy"]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
require "cgi"
|
|
5
5
|
require "fileutils"
|
|
6
6
|
|
|
7
|
+
require_relative "survivor_classification"
|
|
8
|
+
|
|
7
9
|
module Saboteur
|
|
8
10
|
module Reporter
|
|
9
11
|
# One self-contained HTML page: inline style, no script, no network. It is
|
|
@@ -23,7 +25,9 @@ module Saboteur
|
|
|
23
25
|
.status{border-radius:3px;padding:0 .4rem;color:#fff;white-space:nowrap}
|
|
24
26
|
.Killed{background:#2e7d32}.Survived{background:#c62828}.Timeout{background:#b45309}
|
|
25
27
|
.RuntimeError{background:#6a1b9a}.NoCoverage{background:#455a64}.Ignored{background:#616161}
|
|
26
|
-
.Accepted{background:#8d6e63}
|
|
28
|
+
.Accepted,.Triage_accepted_equivalent{background:#8d6e63}
|
|
29
|
+
.Triage_wont_fix{background:#616161}.Triage_todo{background:#b45309}
|
|
30
|
+
.classification{border-radius:3px;padding:0 .4rem;background:#eceff1;color:#37474f;white-space:nowrap}
|
|
27
31
|
.warning{background:#fff8e1;border-left:4px solid #f9a825;margin:.3rem 0;padding:.3rem .6rem;overflow-wrap:anywhere}
|
|
28
32
|
code,pre{font-family:ui-monospace,monospace;font-size:13px}
|
|
29
33
|
pre{display:inline-block;vertical-align:top;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}summary{cursor:pointer;font-weight:600;overflow-wrap:anywhere}
|
|
@@ -195,12 +199,30 @@ module Saboteur
|
|
|
195
199
|
def row_status(mutant, result)
|
|
196
200
|
badge = status_label(mutant[:status])
|
|
197
201
|
# Only survivors are ever put on the ignore list.
|
|
198
|
-
result.ignored
|
|
202
|
+
if result.ignored || result.triage_state
|
|
203
|
+
# An entry without a state predates triage states: render Accepted.
|
|
204
|
+
badge = if result.triage_state.nil?
|
|
205
|
+
"#{badge} #{status_label('Accepted')}"
|
|
206
|
+
else
|
|
207
|
+
"#{badge} #{status_label(triage_badge(result), css_class: "Triage_#{result.triage_state}")}"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
# The survivor's classification, as a label only (#192).
|
|
211
|
+
label = SurvivorClassification.label(result)
|
|
212
|
+
label ? "#{badge} <span class=\"classification\">#{escape(label)}</span>" : badge
|
|
199
213
|
end
|
|
200
214
|
|
|
201
215
|
# One edit shows the original text and its replacement. A mutant with
|
|
202
216
|
# several edits shows only the replacements, because one location cannot
|
|
203
217
|
# stand for all of them.
|
|
218
|
+
def triage_badge(result)
|
|
219
|
+
case result.triage_state
|
|
220
|
+
when "wont_fix" then "Won\u2019t fix"
|
|
221
|
+
when "todo" then "Todo"
|
|
222
|
+
else "Accepted"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
204
226
|
def change(mutant, result)
|
|
205
227
|
edits = result.mutation.edits
|
|
206
228
|
return "<pre>#{escape(mutant[:replacement])}</pre>" unless edits.size == 1
|
|
@@ -212,8 +234,8 @@ module Saboteur
|
|
|
212
234
|
"<pre>#{escape(edit.replacement)}</pre>"
|
|
213
235
|
end
|
|
214
236
|
|
|
215
|
-
def status_label(status)
|
|
216
|
-
%(<span class="status #{
|
|
237
|
+
def status_label(status, css_class: status)
|
|
238
|
+
%(<span class="status #{css_class}">#{status}</span>)
|
|
217
239
|
end
|
|
218
240
|
|
|
219
241
|
def escape(text)
|
|
@@ -20,6 +20,12 @@ module Saboteur
|
|
|
20
20
|
join(result, &:replacement)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# A mutant that only deletes: every edit's replacement is empty.
|
|
24
|
+
# Rendered as "removes X" rather than "X -> " with a blank side.
|
|
25
|
+
def removal?(result)
|
|
26
|
+
sorted_edits(result).all? { |edit| edit.replacement.empty? }
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
def sorted_edits(result)
|
|
24
30
|
result.mutation.edits.sort_by(&:start_byte)
|
|
25
31
|
end
|
|
@@ -39,12 +39,24 @@ module Saboteur
|
|
|
39
39
|
rejectedKills: report.rejected_kills,
|
|
40
40
|
warnings: report.warnings,
|
|
41
41
|
framework: { name: "saboteur", version: VERSION },
|
|
42
|
-
saboteur:
|
|
43
|
-
inlineSuppressions: inline_audit(report) }
|
|
44
|
-
.merge(operatorPolicy: report.operator_policy).merge(exit_cause ? { exitCause: exit_cause } : {})
|
|
42
|
+
saboteur: saboteur_enrichment(report, exit_cause)
|
|
45
43
|
}.merge(report.seed ? { config: { seed: report.seed } } : {})
|
|
46
44
|
end
|
|
47
45
|
|
|
46
|
+
# saboteur-namespaced keys beyond Stryker's schema: findings and
|
|
47
|
+
# scores, then the optional policy, shard, exit-cause and memory keys.
|
|
48
|
+
def self.saboteur_enrichment(report, exit_cause)
|
|
49
|
+
{ findings: Findings.build(report), scores: scores(report),
|
|
50
|
+
inlineSuppressions: inline_audit(report) }
|
|
51
|
+
.merge(operatorPolicy: report.operator_policy).merge(shard(report))
|
|
52
|
+
.merge(exit_cause ? { exitCause: exit_cause } : {})
|
|
53
|
+
.merge(report.memory ? { memory: report.memory } : {})
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.shard(report)
|
|
57
|
+
report.shard ? { shard: report.shard.to_h } : {}
|
|
58
|
+
end
|
|
59
|
+
|
|
48
60
|
def self.inline_audit(report)
|
|
49
61
|
report.inline_suppressions.map { |entry| entry.merge(file: relative(entry[:file], report.root)) }
|
|
50
62
|
end
|
|
@@ -107,7 +119,14 @@ module Saboteur
|
|
|
107
119
|
|
|
108
120
|
def self.optional_fields(result, test_ids)
|
|
109
121
|
{
|
|
122
|
+
# A rejected kill is a verdict, not a tool error: the tests failed
|
|
123
|
+
# but also fail with the method unchanged. Flag it per mutant;
|
|
124
|
+
# the top-level rejectedKills stays a bare count (#375).
|
|
125
|
+
rejectedKill: (true if result.rejected_kill?),
|
|
110
126
|
statusReason: status_reason(result),
|
|
127
|
+
# Why the survivor is worth a look, as a label only: scores and
|
|
128
|
+
# the exit code never change (#192).
|
|
129
|
+
classification: SurvivorClassification.label(result),
|
|
111
130
|
killedBy: result.killed_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
|
|
112
131
|
coveredBy: result.covered_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
|
|
113
132
|
duration: result.runtime && (result.runtime * 1000).round
|
|
@@ -129,6 +148,11 @@ module Saboteur
|
|
|
129
148
|
end
|
|
130
149
|
|
|
131
150
|
def self.status_reason(result)
|
|
151
|
+
return "todo: #{result.triage_reason}" if result.triage_state == "todo"
|
|
152
|
+
# The "ignored: " prefix is a public contract consumers classify by
|
|
153
|
+
# (#304 CI): accepted_equivalent stays byte-identical to a plain
|
|
154
|
+
# ignore, and only wont_fix adds a suffix after the reason.
|
|
155
|
+
return "ignored: #{result.ignored} (wont_fix)" if result.triage_state == "wont_fix"
|
|
132
156
|
return "ignored: #{result.ignored}" if result.ignored
|
|
133
157
|
return "resource limit: #{result.reason}" if result.status == :resource_limit
|
|
134
158
|
return result.crash_kill? ? "crash: #{result.kill_cause}" : result.kill_cause if result.kill_cause
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../engine/bytecode_equivalence"
|
|
5
|
+
require_relative "../execution/external_runner"
|
|
6
|
+
require_relative "../mutation_source/builtin"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Reporter
|
|
10
|
+
# Why a survivor is worth a look, as a LABEL ONLY (#192): the label
|
|
11
|
+
# lands in reports so a reader can sort real gaps from noise. Every
|
|
12
|
+
# label is a HEURISTIC, not a diagnosis - inspect the survivor before
|
|
13
|
+
# acting on it, and never add a test or ignore a mutant from the label
|
|
14
|
+
# alone. Labels never change scores or the exit code.
|
|
15
|
+
#
|
|
16
|
+
# One label per survivor, first match wins:
|
|
17
|
+
# likely_equivalent - the mutant compiles to the same instructions as
|
|
18
|
+
# the original (the #151/#169 bytecode filter): strong evidence of
|
|
19
|
+
# a no-op mutant, worth checking against the source. First: a
|
|
20
|
+
# no-op mutant's survival says nothing about the suite.
|
|
21
|
+
# arid - every line an edit touches matches an arid pattern
|
|
22
|
+
# (logging and friends, #111/#187). The builtin hint list decides;
|
|
23
|
+
# a configured arid policy does not change the label.
|
|
24
|
+
# missing_test - a test covered the line and passed, which MAY mean
|
|
25
|
+
# the suite reaches the code without pinning this behaviour. The
|
|
26
|
+
# covering list is only the PLANNED coverage: whole-body (coarse)
|
|
27
|
+
# mutants carry method-level guesses, so they never get this
|
|
28
|
+
# label, and neither does anything without real coverage.
|
|
29
|
+
# tool_limit - reserved for survivors saboteur could not observe
|
|
30
|
+
# faithfully (re-eval refusal, external runner without coverage). No
|
|
31
|
+
# result records that signal yet, so the label is never emitted.
|
|
32
|
+
#
|
|
33
|
+
# A survivor with no matching signal stays unlabeled.
|
|
34
|
+
module SurvivorClassification
|
|
35
|
+
LABELS = %w[likely_equivalent arid missing_test tool_limit].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def label(result, arid: MutationSource::Builtin::DEFAULT_ARID)
|
|
39
|
+
return nil unless result.status == :survived
|
|
40
|
+
return "likely_equivalent" if equivalent?(result)
|
|
41
|
+
return "arid" if arid?(result, arid)
|
|
42
|
+
return "missing_test" if possible_test_gap?(result)
|
|
43
|
+
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The mutant's own def text, compiled side by side with the
|
|
50
|
+
# original: equal instruction sequences mean behaviour cannot
|
|
51
|
+
# change. Edits index the whole file, so they shift by the def's
|
|
52
|
+
# start byte before splicing into the def text.
|
|
53
|
+
def equivalent?(result)
|
|
54
|
+
subject = result.subject
|
|
55
|
+
original = subject.source
|
|
56
|
+
mutated = result.mutation.edits.sort_by(&:start_byte).reverse.inject(original) do |text, edit|
|
|
57
|
+
from = edit.start_byte - subject.start_byte
|
|
58
|
+
to = edit.end_byte - subject.start_byte
|
|
59
|
+
text.byteslice(0, from) + edit.replacement + text.byteslice(to..)
|
|
60
|
+
end
|
|
61
|
+
Engine::BytecodeEquivalence.new.equivalent?(original, mutated,
|
|
62
|
+
frozen_string_literal: subject.frozen_literals)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# covered_by is a PLANNED covering list, not a verdict about what
|
|
66
|
+
# the suite asserts: coarse (whole-body) mutants carry method-level
|
|
67
|
+
# guesses, and a --test-command run records only the pseudo-test id
|
|
68
|
+
# for every mutant, so neither reads as real coverage (#305 QA).
|
|
69
|
+
def possible_test_gap?(result)
|
|
70
|
+
real_coverage = result.covered_by - [Execution::ExternalRunner::PSEUDO_TEST]
|
|
71
|
+
!result.mutation.coarse && real_coverage.any?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Arid only when EVERY line an edit touches reads as arid: an edit
|
|
75
|
+
# that starts on a logging line but removes a composite body is not
|
|
76
|
+
# arid. The operator never decides: removing a logging statement is
|
|
77
|
+
# arid whatever removed it.
|
|
78
|
+
def arid?(result, patterns)
|
|
79
|
+
result.mutation.edits.all? do |edit|
|
|
80
|
+
lines = edited_lines(result.subject, edit)
|
|
81
|
+
lines.any? && lines.all? do |line|
|
|
82
|
+
patterns.any? { |pattern| line.strip.match?(Regexp.new(pattern)) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Edit offsets are BYTES, so the line lookup runs on the binary
|
|
88
|
+
# view (index/rindex are byte-exact there) and only the extracted
|
|
89
|
+
# lines are decoded, scrubbed so a partial multibyte sequence at an
|
|
90
|
+
# edit boundary cannot raise on the pattern match.
|
|
91
|
+
def edited_lines(subject, edit)
|
|
92
|
+
bytes = subject.file_source.b
|
|
93
|
+
last = [edit.end_byte - 1, edit.start_byte].max
|
|
94
|
+
line_start = (bytes.byteslice(0, edit.start_byte).rindex("\n") || -1) + 1
|
|
95
|
+
line_end = bytes.index("\n", last) || bytes.bytesize
|
|
96
|
+
bytes.byteslice(line_start, line_end - line_start).force_encoding(Encoding::UTF_8).scrub.lines
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -47,14 +47,16 @@ module Saboteur
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# Mutants on one line share the printed evidence
|
|
51
|
-
# same
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
50
|
+
# Mutants on one line share the printed evidence when it names the
|
|
51
|
+
# same tests: the covering-test list is compared as a set (#502), so
|
|
52
|
+
# one line's mutants no longer reprint an identical block only because
|
|
53
|
+
# the run measured the killer order per mutant. Reuse and changed
|
|
54
|
+
# lines still group exactly. Each run of neighbours with the same key
|
|
55
|
+
# is one group and shares evidence above its own mutations; evidence
|
|
56
|
+
# that repeats after another group is printed again, which keeps the
|
|
57
|
+
# ranked order. The block prints in the first mutant's run order.
|
|
56
58
|
def evidence_groups(report, on_line)
|
|
57
|
-
on_line.chunk { |result| [result.covered_by, result.reused?, report.changed_lines_for(result)] }
|
|
59
|
+
on_line.chunk { |result| [result.covered_by.uniq.sort, result.reused?, report.changed_lines_for(result)] }
|
|
58
60
|
.map(&:last)
|
|
59
61
|
end
|
|
60
62
|
|
|
@@ -70,9 +72,13 @@ module Saboteur
|
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
def mutation(result)
|
|
75
|
+
change = if MutationText.removal?(result)
|
|
76
|
+
"removes #{MutationText.original(result)}"
|
|
77
|
+
else
|
|
78
|
+
"#{MutationText.original(result)} -> #{@palette.paint(:green, MutationText.replacement(result))}"
|
|
79
|
+
end
|
|
73
80
|
text = " #{@palette.paint(:red, '-')} #{result.mutation.operator} " \
|
|
74
|
-
"#{
|
|
75
|
-
"#{result.digest}"
|
|
81
|
+
"#{change} #{result.digest}"
|
|
76
82
|
text += " (ignored: #{result.ignored})" if result.ignored
|
|
77
83
|
@out.puts text
|
|
78
84
|
hint(result)
|
|
@@ -91,6 +91,7 @@ module Saboteur
|
|
|
91
91
|
seed = report.seed && ", seed #{report.seed} (repeat with --seed #{report.seed})"
|
|
92
92
|
@out.puts "saboteur #{VERSION}: #{report.subjects.size} subjects, #{report.results.size} mutants, " \
|
|
93
93
|
"wall #{format('%.2f', report.wall_time)} s#{seed}"
|
|
94
|
+
@out.puts report.shard.line if report.shard
|
|
94
95
|
@out.puts status_line(report)
|
|
95
96
|
details(report)
|
|
96
97
|
@score_table.call(report)
|
|
@@ -103,10 +104,12 @@ module Saboteur
|
|
|
103
104
|
survived = "survived #{c[:survived]}"
|
|
104
105
|
ignored = report.survivors.size - report.unignored_survivors.size
|
|
105
106
|
survived += " (#{ignored} ignored)" if ignored.positive?
|
|
107
|
+
rejected = report.rejected_kills
|
|
108
|
+
rejected_note = " (#{rejected} rejected kill#{'s' if rejected > 1})" if rejected.positive?
|
|
106
109
|
line = "#{@palette.paint(:green,
|
|
107
110
|
"killed #{c[:killed]}")} #{@palette.paint(:red,
|
|
108
111
|
survived)} timeout #{c[:timeout]} " \
|
|
109
|
-
"resource limit #{c[:resource_limit]} error #{c[:error]} skipped #{c[:skipped]}"
|
|
112
|
+
"resource limit #{c[:resource_limit]} error #{c[:error]}#{rejected_note} skipped #{c[:skipped]}"
|
|
110
113
|
line += " ran at load #{c[:ran_at_load]}" if c[:ran_at_load].positive?
|
|
111
114
|
line += " unreachable #{c[:unreachable]}" if c[:unreachable].positive?
|
|
112
115
|
line += " unverified #{c[:unverified]}" if c[:unverified].positive?
|
|
@@ -122,11 +125,19 @@ module Saboteur
|
|
|
122
125
|
|
|
123
126
|
def details(report)
|
|
124
127
|
breakdown = report.score_breakdown
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
# The share covers scored mutants only, so an error-dominated run
|
|
129
|
+
# must not print it as a bare score: "score 100.0 %" above "the run
|
|
130
|
+
# tested almost nothing" reads as a contradiction (#557).
|
|
131
|
+
if report.majority_errored?
|
|
132
|
+
@out.puts "score n/a (#{breakdown.tested} of #{report.executed.size} mutants scored, " \
|
|
133
|
+
"#{percent(breakdown.value)} of scored)"
|
|
134
|
+
else
|
|
135
|
+
@out.puts "score #{percent(breakdown.value)}" \
|
|
136
|
+
"#{if @verbose
|
|
137
|
+
"#{score_split(breakdown)} (killed+timeout)/(killed+survived+timeout); " \
|
|
138
|
+
'errors, resource limits and skips excluded'
|
|
139
|
+
end}"
|
|
140
|
+
end
|
|
130
141
|
report.skip_reasons.each { |reason, count| @out.puts "skipped: #{reason}: #{count}" }
|
|
131
142
|
reused = report.results.count(&:reused?)
|
|
132
143
|
@out.puts "reused #{reused} of #{report.results.size} verdicts from earlier runs (--incremental)" if
|
|
@@ -233,6 +244,7 @@ module Saboteur
|
|
|
233
244
|
label = stop.reason == "fail_fast" ? "--fail-fast" : "--max-duration spent"
|
|
234
245
|
@out.puts @palette.paint(:yellow, "stopped early (#{label}): #{early_stop_summary(stop)}; the score below " \
|
|
235
246
|
"covers only completed work")
|
|
247
|
+
budget_shortfall(stop, report.results.size)
|
|
236
248
|
list(stop.unreached) { |id| " not run: #{id}" }
|
|
237
249
|
list(stop.truncated) { |entry| " stopped mid-way: #{entry[:subject]} (#{entry[:omitted]} mutants omitted)" }
|
|
238
250
|
list(stop.unverified) { |id| " unverified: #{id}" }
|
|
@@ -243,6 +255,17 @@ module Saboteur
|
|
|
243
255
|
@out.puts " ... #{entries.size - MAX_ERRORS} more" if entries.size > MAX_ERRORS
|
|
244
256
|
end
|
|
245
257
|
|
|
258
|
+
def budget_shortfall(stop, completed)
|
|
259
|
+
return unless stop.reason == "max_duration"
|
|
260
|
+
|
|
261
|
+
line = if stop.planned_mutants
|
|
262
|
+
"partial: #{stop.planned_mutants - completed} of #{stop.planned_mutants} mutants not run"
|
|
263
|
+
else
|
|
264
|
+
"partial: mutant count unknown; the budget was spent before planning finished"
|
|
265
|
+
end
|
|
266
|
+
@out.puts @palette.paint(:yellow, line)
|
|
267
|
+
end
|
|
268
|
+
|
|
246
269
|
def early_stop_summary(stop)
|
|
247
270
|
parts = []
|
|
248
271
|
if stop.unreached.any?
|
|
@@ -264,10 +287,20 @@ module Saboteur
|
|
|
264
287
|
end
|
|
265
288
|
|
|
266
289
|
def errors(report)
|
|
267
|
-
|
|
290
|
+
# Rejected kills are verdicts, not tool errors: the tests failed,
|
|
291
|
+
# but they also fail with the method unchanged (#375).
|
|
292
|
+
rejected, errored = report.results.select { |result| result.status == :error }.partition(&:rejected_kill?)
|
|
293
|
+
unless rejected.empty?
|
|
294
|
+
@out.puts "\nRejected kills (tests also fail with the method unchanged; not counted as killed):"
|
|
295
|
+
print_error_lines(rejected)
|
|
296
|
+
end
|
|
268
297
|
return if errored.empty?
|
|
269
298
|
|
|
270
299
|
@out.puts "\nErrors (not counted as killed):"
|
|
300
|
+
print_error_lines(errored)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def print_error_lines(errored)
|
|
271
304
|
errored.first(MAX_ERRORS).each do |result|
|
|
272
305
|
lines = result.reason.to_s.lines.map(&:strip)
|
|
273
306
|
@out.puts " #{result.subject.id} #{result.mutation.operator} #{result.digest}: #{lines.first}"
|
data/lib/saboteur/result.rb
CHANGED
|
@@ -23,6 +23,11 @@ module Saboteur
|
|
|
23
23
|
attr_reader :subject, :mutation, :status, :reason, :runtime, :killed_by, :covered_by, :mutated_source,
|
|
24
24
|
:kill_cause, :executed
|
|
25
25
|
attr_accessor :ignored
|
|
26
|
+
# The ignore list's triage for this survivor (#155): a state from
|
|
27
|
+
# IgnoreList::STATES and its reason. accepted_equivalent and wont_fix
|
|
28
|
+
# suppress the exit failure (the survivor still counts in the score); a
|
|
29
|
+
# todo survivor keeps counting as an unignored survivor.
|
|
30
|
+
attr_accessor :triage_state, :triage_reason
|
|
26
31
|
|
|
27
32
|
def initialize(subject:, mutation:, status:, mutated_source:, reason: nil, runtime: nil, killed_by: [],
|
|
28
33
|
covered_by: [], kill_cause: nil, reused: false, rejected_kill: false, executed: nil)
|
|
@@ -41,6 +46,9 @@ module Saboteur
|
|
|
41
46
|
@rejected_kill = rejected_kill
|
|
42
47
|
@reused = reused
|
|
43
48
|
@ignored = nil
|
|
49
|
+
# triage_state/triage_reason stay unset until the ignore list writes
|
|
50
|
+
# them: the accessors read as nil before that, and there is no
|
|
51
|
+
# statement for a mutation to remove (#304 dogfood).
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
# The same mutant, marked unverified: the budget ran out before its
|
|
@@ -117,7 +125,11 @@ module Saboteur
|
|
|
117
125
|
# subjects that started but stopped mid-way as {subject:, omitted:}
|
|
118
126
|
# entries; unverified lists the subjects whose kills the budget left
|
|
119
127
|
# unchecked. The verdict covers only completed, verified work.
|
|
120
|
-
EarlyStop = Data.define(:reason, :unreached, :truncated, :unverified)
|
|
128
|
+
EarlyStop = Data.define(:reason, :unreached, :truncated, :unverified, :planned_mutants) do
|
|
129
|
+
def initialize(reason:, unreached:, truncated:, unverified:, planned_mutants: nil)
|
|
130
|
+
super
|
|
131
|
+
end
|
|
132
|
+
end
|
|
121
133
|
|
|
122
134
|
# Everything one run produced.
|
|
123
135
|
class Report
|
|
@@ -127,14 +139,14 @@ module Saboteur
|
|
|
127
139
|
|
|
128
140
|
attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects,
|
|
129
141
|
:generation_events, :omissions, :run_evidence, :hazard_suppressions, :early_stop, :inline_suppressions,
|
|
130
|
-
:operator_policy, :discovery_failures
|
|
142
|
+
:operator_policy, :discovery_failures, :memory, :shard
|
|
131
143
|
|
|
132
144
|
# REVIEW: diff mode; the terminal then shows one survivor per line.
|
|
133
145
|
# seed: the random seed every test run started from (nil when unknown).
|
|
134
146
|
def initialize(results:, subjects:, wall_time:, root:, invalid_mutations: 0, warnings: [], review: false,
|
|
135
147
|
seed: nil, skipped_subjects: {}, diff_scope: nil, generation_events: [], omissions: [],
|
|
136
148
|
run_evidence: nil, hazard_suppressions: 0, early_stop: nil, matrix: false, inline_suppressions: [],
|
|
137
|
-
operator_policy: nil, discovery_failures: [])
|
|
149
|
+
operator_policy: nil, discovery_failures: [], memory: nil, shard: nil)
|
|
138
150
|
@results = results
|
|
139
151
|
@subjects = subjects
|
|
140
152
|
@wall_time = wall_time
|
|
@@ -154,6 +166,10 @@ module Saboteur
|
|
|
154
166
|
@inline_suppressions = inline_suppressions.dup.freeze
|
|
155
167
|
@operator_policy = operator_policy
|
|
156
168
|
@discovery_failures = discovery_failures.dup.freeze
|
|
169
|
+
# Per-worker and supervisor peak RSS in MB, when the platform measured
|
|
170
|
+
# it (#202); nil elsewhere, so JSON reports omit the section.
|
|
171
|
+
@memory = memory.dup.freeze
|
|
172
|
+
@shard = shard
|
|
157
173
|
end
|
|
158
174
|
|
|
159
175
|
# A --matrix run: every covering test ran per mutant.
|