saboteur 0.4.0 → 0.6.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 +199 -0
- data/README.md +427 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +106 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +124 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +113 -122
- data/lib/saboteur/config.rb +96 -6
- data/lib/saboteur/doctor.rb +64 -9
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +62 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +7 -5
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +89 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +201 -19
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +32 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +115 -0
- data/lib/saboteur/reporter/agent_commands.rb +19 -5
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +12 -6
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +131 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +135 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +18 -3
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +140 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +44 -6
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +46 -2
|
@@ -15,7 +15,8 @@ module Saboteur
|
|
|
15
15
|
|
|
16
16
|
# Options typed on the command line that change how a run behaves.
|
|
17
17
|
SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
|
|
18
|
-
timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html"
|
|
18
|
+
timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html",
|
|
19
|
+
operators: "--operators", exclude_operators: "--exclude-operators" }.freeze
|
|
19
20
|
|
|
20
21
|
class << self
|
|
21
22
|
# Run the same selection again: ref, subjects, sources, tests, config,
|
|
@@ -40,15 +41,28 @@ module Saboteur
|
|
|
40
41
|
command.bytesize <= MAX_BYTES ? command : nil
|
|
41
42
|
end
|
|
42
43
|
|
|
44
|
+
# An explicit scope is frozen: --no-guess-tests keeps a config-file
|
|
45
|
+
# guess_tests from conflicting with the --test list.
|
|
46
|
+
def test_words(tests)
|
|
47
|
+
tests.empty? ? [] : ["--no-guess-tests", *tests.flat_map { |test| ["--test", test] }]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def setting_words(provenance)
|
|
51
|
+
SETTING_FLAGS.flat_map do |key, flag|
|
|
52
|
+
next [] unless provenance.settings.key?(key)
|
|
53
|
+
|
|
54
|
+
value = provenance.settings[key]
|
|
55
|
+
[flag, value.is_a?(Array) ? value.join(",") : value.to_s]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
43
59
|
def selection(provenance, replay: false)
|
|
44
60
|
words = []
|
|
45
61
|
words.push("--config", provenance.config_path) if provenance.config_path
|
|
46
62
|
words.push("--since", provenance.ref) if provenance.ref
|
|
47
63
|
provenance.subjects.each { |subject| words.push("--subject", subject) }
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
words.push(flag, provenance.settings[key].to_s) if provenance.settings.key?(key)
|
|
51
|
-
end
|
|
64
|
+
words.push(*test_words(provenance.tests))
|
|
65
|
+
words.concat(setting_words(provenance))
|
|
52
66
|
words << "--no-cache" if provenance.cache == false
|
|
53
67
|
words << "--incremental" if provenance.incremental
|
|
54
68
|
words.push("--id", provenance.digest) if provenance.digest && !replay
|
|
@@ -7,24 +7,47 @@ module Saboteur
|
|
|
7
7
|
# What makes a verdict cover less than the whole selection. Each reason has a
|
|
8
8
|
# stable code, a count and a plain meaning.
|
|
9
9
|
module Incomplete
|
|
10
|
+
NO_COVERAGE = "no coverage"
|
|
11
|
+
|
|
10
12
|
class << self
|
|
11
13
|
def reasons(report)
|
|
12
14
|
reasons = []
|
|
13
15
|
add(reasons, "no_coverage", skipped_for_coverage(report), "subjects or mutants had no covering test")
|
|
16
|
+
load_only = report.counts[:ran_at_load] + report.skipped_subjects.values.count do |reason|
|
|
17
|
+
reason == Selection::CoverageMap::RAN_AT_LOAD
|
|
18
|
+
end
|
|
19
|
+
add(reasons, "unsupported", unsupported_subjects(report),
|
|
20
|
+
"subjects were skipped as unsupported (for example defined inside a block); no mutant was judged")
|
|
21
|
+
add(reasons, "ran_at_load", load_only, "code ran only at load; no mutant was judged")
|
|
22
|
+
add(reasons, "unreachable", report.counts[:unreachable], "tests did not call the replacement definition")
|
|
14
23
|
add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
|
|
15
24
|
add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
|
|
16
25
|
add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
|
|
26
|
+
add(reasons, "fail_fast", report.early_stop&.unreached&.size.to_i,
|
|
27
|
+
"selected subjects did not run because --fail-fast stopped the run")
|
|
28
|
+
inline_scope(reasons, report)
|
|
17
29
|
reasons
|
|
18
30
|
end
|
|
19
31
|
|
|
20
32
|
private
|
|
21
33
|
|
|
34
|
+
def inline_scope(reasons, report)
|
|
35
|
+
add(reasons, "inline_suppressed", report.inline_suppressions.size,
|
|
36
|
+
"candidates were suppressed by inline directives; see the full report audit")
|
|
37
|
+
end
|
|
38
|
+
|
|
22
39
|
def add(reasons, code, count, meaning)
|
|
23
40
|
reasons << { reason: code, count: count, meaning: meaning } if count.positive?
|
|
24
41
|
end
|
|
25
42
|
|
|
26
43
|
def skipped_for_coverage(report)
|
|
27
|
-
report.skipped_subjects.
|
|
44
|
+
report.skipped_subjects.values.count(NO_COVERAGE) + report.counts[:skipped]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def unsupported_subjects(report)
|
|
48
|
+
report.skipped_subjects.values.count do |reason|
|
|
49
|
+
reason != NO_COVERAGE && reason != Selection::CoverageMap::RAN_AT_LOAD
|
|
50
|
+
end
|
|
28
51
|
end
|
|
29
52
|
|
|
30
53
|
def omitted_by_cap(report)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "findings"
|
|
5
|
+
require_relative "survivor_hints"
|
|
5
6
|
require_relative "agent_incomplete"
|
|
6
7
|
require_relative "agent_clipping"
|
|
7
8
|
|
|
@@ -21,12 +22,12 @@ module Saboteur
|
|
|
21
22
|
verdict = verdict_of(report, status)
|
|
22
23
|
{
|
|
23
24
|
version: VERSION, status: status, verdict: verdict, exitCode: exit_code_for(verdict),
|
|
24
|
-
provenance: provenance_hash(provenance),
|
|
25
|
+
provenance: provenance_hash(provenance).merge(operatorPolicy: report.operator_policy),
|
|
25
26
|
counts: counts(report), score: score(report),
|
|
26
27
|
scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
|
|
27
28
|
incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
|
|
28
29
|
findings: findings(report, shown, snippet_limit),
|
|
29
|
-
warnings: warnings(
|
|
30
|
+
warnings: report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] },
|
|
30
31
|
next: next_steps(report, provenance)
|
|
31
32
|
}
|
|
32
33
|
end
|
|
@@ -34,7 +35,7 @@ module Saboteur
|
|
|
34
35
|
private
|
|
35
36
|
|
|
36
37
|
def status_of(report, incomplete)
|
|
37
|
-
return "not_applicable" if report.results.empty? &&
|
|
38
|
+
return "not_applicable" if report.results.empty? && incomplete.empty?
|
|
38
39
|
|
|
39
40
|
incomplete.empty? ? "complete" : "partial"
|
|
40
41
|
end
|
|
@@ -87,7 +88,8 @@ module Saboteur
|
|
|
87
88
|
{
|
|
88
89
|
survivors: live, shown: shown_findings(all[:survivors], shown, snippet_limit),
|
|
89
90
|
omitted: live - [shown, all[:survivors].size].min,
|
|
90
|
-
accepted: report.survivors.size - live, ordering: all[:ordering]
|
|
91
|
+
accepted: report.survivors.size - live, ordering: all[:ordering],
|
|
92
|
+
hintCaveat: SurvivorHints::CAVEAT
|
|
91
93
|
}
|
|
92
94
|
end
|
|
93
95
|
|
|
@@ -97,11 +99,15 @@ module Saboteur
|
|
|
97
99
|
.merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
|
|
98
100
|
original: clip(finding[:original], snippet_limit),
|
|
99
101
|
replacement: clip(finding[:replacement], snippet_limit))
|
|
102
|
+
.merge(finding_hints(finding))
|
|
100
103
|
end
|
|
101
104
|
end
|
|
102
105
|
|
|
103
|
-
def
|
|
104
|
-
|
|
106
|
+
def finding_hints(finding)
|
|
107
|
+
structured = SurvivorHints.structured(operator: finding[:operator], original: finding[:original],
|
|
108
|
+
replacement: finding[:replacement], coarse: finding[:coarse])
|
|
109
|
+
{ hint: SurvivorHints.for(finding[:operator], coarse: finding[:coarse]) }.compact
|
|
110
|
+
.merge(testHint: structured)
|
|
105
111
|
end
|
|
106
112
|
|
|
107
113
|
def next_steps(report, provenance)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "survivor_ranking"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
module Reporter
|
|
6
8
|
# Findings for a code-review surface such as the saboteur GitHub Action:
|
|
@@ -15,12 +17,7 @@ module Saboteur
|
|
|
15
17
|
# GitHub shows at most 10 annotations of one kind per step.
|
|
16
18
|
LIMIT = 10
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
# replaced the whole method body, which hints that no test pins the
|
|
20
|
-
# method's behavior - though it can also be a re-evaluation limit or an
|
|
21
|
-
# equivalent mutant, so this is a heuristic, not a guarantee. Ties break
|
|
22
|
-
# by file, line and digest, so one report always reads the same way.
|
|
23
|
-
ORDERING = "coarse survivors first, then file, line and digest"
|
|
20
|
+
ORDERING = SurvivorRanking::ORDERING
|
|
24
21
|
|
|
25
22
|
class << self
|
|
26
23
|
def build(report)
|
|
@@ -46,20 +43,8 @@ module Saboteur
|
|
|
46
43
|
[results.size - LIMIT, 0].max
|
|
47
44
|
end
|
|
48
45
|
|
|
49
|
-
# partition keeps the coarse group ahead; inside a group the key is
|
|
50
|
-
# total (no two mutants share a digest), so the order is stable.
|
|
51
46
|
def ordered(report, results)
|
|
52
|
-
|
|
53
|
-
.map { |group| group.sort_by { |result| tie_break(report, result) } }
|
|
54
|
-
.flatten
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def tie_break(report, result)
|
|
58
|
-
[path(report, result), first_line(result), result.digest]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def first_line(result)
|
|
62
|
-
result.subject.line_of(result.mutation.edits.map(&:start_byte).min)
|
|
47
|
+
SurvivorRanking.ordered(report, results)
|
|
63
48
|
end
|
|
64
49
|
|
|
65
50
|
def finding(report, result)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# Refuses linked or non-regular history/report destinations before writes.
|
|
7
|
+
module HistoryPaths
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def validate(paths)
|
|
11
|
+
paths = paths.compact.map { |path| File.expand_path(path) }
|
|
12
|
+
identities = paths.map do |path|
|
|
13
|
+
reject_links(path)
|
|
14
|
+
next unless File.exist?(path)
|
|
15
|
+
|
|
16
|
+
stat = File.stat(path)
|
|
17
|
+
raise Error, "score history/report destination must be a regular file" unless stat.file?
|
|
18
|
+
|
|
19
|
+
[stat.dev, stat.ino]
|
|
20
|
+
end.compact
|
|
21
|
+
raise Error, "score history/report destinations alias the same file" unless identities.uniq == identities
|
|
22
|
+
rescue SystemCallError => e
|
|
23
|
+
raise Error, "score history/report paths cannot be verified: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reject_links(path)
|
|
27
|
+
loop do
|
|
28
|
+
raise Error, "score history/report paths must not contain symlinks" if File.symlink?(path)
|
|
29
|
+
|
|
30
|
+
parent = File.dirname(path)
|
|
31
|
+
break if parent == path
|
|
32
|
+
|
|
33
|
+
path = parent
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# v1 stays readable, but v2 records require versioned operator evidence.
|
|
7
|
+
module HistoryPolicy
|
|
8
|
+
def validate_header(record, fields)
|
|
9
|
+
version = record.is_a?(Hash) && record["version"]
|
|
10
|
+
raise Error, "score history contains an invalid version" unless version.is_a?(Integer) && [1,
|
|
11
|
+
2].include?(version)
|
|
12
|
+
|
|
13
|
+
fields(record, version == 2 ? fields + ["operatorPolicy"] : fields)
|
|
14
|
+
validate_policy(record["operatorPolicy"]) if version == 2
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def validate_policy(value)
|
|
18
|
+
raise Error, "score history contains invalid operator policy" unless value.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
fields(value, %w[version selected excluded effective])
|
|
21
|
+
raise Error, "score history contains invalid operator policy" if value["effective"].empty?
|
|
22
|
+
|
|
23
|
+
lists = value.values_at("selected", "excluded", "effective")
|
|
24
|
+
unless lists.all? { |list| list.is_a?(Array) && list.all?(String) }
|
|
25
|
+
raise Error, "score history contains invalid operator policy"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
expected = MutationSource::OperatorRegistry.policy(value["selected"], value["excluded"])
|
|
29
|
+
raise Error, "score history contains invalid operator policy" unless value == expected
|
|
30
|
+
rescue ConfigError, NoMethodError, TypeError
|
|
31
|
+
raise Error, "score history contains invalid operator policy"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "history_policy"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Reporter
|
|
10
|
+
# Strict version-one observation schema. Never repairs imported evidence.
|
|
11
|
+
module HistoryRecord
|
|
12
|
+
FIELDS = %w[version date commit selection inventory score coveredScore counts breakdown incomplete].freeze
|
|
13
|
+
BREAKDOWN = %w[score tested assertionKills crashKills unclassifiedKills timeouts survivors
|
|
14
|
+
assertionShare crashShare].freeze
|
|
15
|
+
COUNTS = STATUSES.map(&:to_s).freeze
|
|
16
|
+
|
|
17
|
+
class UniqueObject < Hash
|
|
18
|
+
def []=(key, value)
|
|
19
|
+
raise Error, "score history contains duplicate keys" if key?(key)
|
|
20
|
+
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
extend HistoryPolicy
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def parse(line)
|
|
30
|
+
record = JSON.parse(line, object_class: UniqueObject)
|
|
31
|
+
validate(record)
|
|
32
|
+
record
|
|
33
|
+
rescue JSON::ParserError, ArgumentError, TypeError => e
|
|
34
|
+
raise Error, "score history contains an invalid record: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate(record)
|
|
38
|
+
validate_header(record, FIELDS)
|
|
39
|
+
|
|
40
|
+
date(record.fetch("date"))
|
|
41
|
+
commit = record.fetch("commit")
|
|
42
|
+
raise Error, "score history contains an invalid commit" unless commit.nil? || sha?(commit, 40)
|
|
43
|
+
|
|
44
|
+
%w[selection inventory].each do |key|
|
|
45
|
+
raise Error, "score history contains an invalid digest" unless sha?(record.fetch(key), 64)
|
|
46
|
+
end
|
|
47
|
+
%w[score coveredScore].each { |key| fraction(record.fetch(key)) }
|
|
48
|
+
counts = record.fetch("counts")
|
|
49
|
+
fields(counts, COUNTS)
|
|
50
|
+
counts.each_value { |value| count(value) }
|
|
51
|
+
breakdown(record.fetch("breakdown"), counts, record.fetch("coveredScore"))
|
|
52
|
+
score_consistency(record)
|
|
53
|
+
reasons(record.fetch("incomplete"))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def date(value)
|
|
57
|
+
return if Time.iso8601(value).utc.iso8601 == value
|
|
58
|
+
|
|
59
|
+
raise Error,
|
|
60
|
+
"score history contains an invalid date"
|
|
61
|
+
rescue ArgumentError, TypeError, NoMethodError => e
|
|
62
|
+
raise Error, "score history contains an invalid date: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reasons(value)
|
|
66
|
+
unless value.is_a?(Array) && value.all? do |reason|
|
|
67
|
+
reason.is_a?(String) && !reason.empty?
|
|
68
|
+
end && value.uniq == value
|
|
69
|
+
raise Error, "score history contains invalid incomplete reasons"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def score_consistency(record)
|
|
74
|
+
value = record.fetch("breakdown")
|
|
75
|
+
detected = value.values_at("assertionKills", "crashKills", "unclassifiedKills", "timeouts").sum
|
|
76
|
+
tested = value.fetch("tested")
|
|
77
|
+
expected = tested.zero? ? nil : detected.fdiv(tested)
|
|
78
|
+
raise Error, "score history contains an inconsistent score" unless record.fetch("coveredScore") == expected
|
|
79
|
+
|
|
80
|
+
%w[assertion crash].each do |kind|
|
|
81
|
+
expected_share = tested.zero? ? nil : value.fetch("#{kind}Kills").fdiv(tested)
|
|
82
|
+
unless value.fetch("#{kind}Share") == expected_share
|
|
83
|
+
raise Error,
|
|
84
|
+
"score history contains an inconsistent share"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
overall = record.fetch("score")
|
|
88
|
+
return unless overall && ((record.fetch("coveredScore").nil? && !overall.zero?) ||
|
|
89
|
+
(record.fetch("coveredScore") && overall > record.fetch("coveredScore")))
|
|
90
|
+
|
|
91
|
+
raise Error, "score history contains an inconsistent overall score"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def fields(object, expected)
|
|
95
|
+
return if object.is_a?(Hash) && object.keys.sort == expected.sort
|
|
96
|
+
|
|
97
|
+
raise Error,
|
|
98
|
+
"score history contains an invalid record schema"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sha?(value, length)
|
|
102
|
+
value.is_a?(String) && value.match?(/\A[0-9a-f]{#{length}}\z/)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fraction(value)
|
|
106
|
+
return if value.nil? || (0..1).cover?(value)
|
|
107
|
+
|
|
108
|
+
raise Error,
|
|
109
|
+
"score history contains an invalid score"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def count(value)
|
|
113
|
+
raise Error, "score history contains an invalid count" unless value.is_a?(Integer) && value >= 0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def breakdown(value, counts, covered)
|
|
117
|
+
fields(value, BREAKDOWN)
|
|
118
|
+
%w[score assertionShare crashShare].each { |key| fraction(value.fetch(key)) }
|
|
119
|
+
(BREAKDOWN - %w[score assertionShare crashShare]).each { |key| count(value.fetch(key)) }
|
|
120
|
+
kills = value.values_at("assertionKills", "crashKills", "unclassifiedKills").sum
|
|
121
|
+
tested = counts.values_at("killed", "survived", "timeout").sum
|
|
122
|
+
unless kills == counts.fetch("killed") && value.fetch("tested") == tested &&
|
|
123
|
+
value.fetch("timeouts") == counts.fetch("timeout") &&
|
|
124
|
+
value.fetch("survivors") == counts.fetch("survived") &&
|
|
125
|
+
value.fetch("score") == covered
|
|
126
|
+
raise Error, "score history contains inconsistent counts"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -57,7 +57,7 @@ module Saboteur
|
|
|
57
57
|
<title>saboteur report</title>
|
|
58
58
|
<style>#{STYLE}</style></head><body>
|
|
59
59
|
<h1>saboteur report</h1>
|
|
60
|
-
#{summary(report, data)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
|
|
60
|
+
#{summary(report, data)}#{inline_suppressions(report)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
|
|
61
61
|
<h2>Files</h2>
|
|
62
62
|
#{data[:files].map { |file, entry| file_section(file, entry, names, results) }.join("\n")}
|
|
63
63
|
</body></html>
|
|
@@ -66,6 +66,17 @@ module Saboteur
|
|
|
66
66
|
|
|
67
67
|
private
|
|
68
68
|
|
|
69
|
+
def inline_suppressions(report)
|
|
70
|
+
return "" if report.inline_suppressions.empty?
|
|
71
|
+
|
|
72
|
+
items = report.inline_suppressions.map do |entry|
|
|
73
|
+
path = entry[:file].delete_prefix("#{report.root}/")
|
|
74
|
+
"<li>#{escape(path)}:#{entry[:line]} #{escape(entry[:subject])} " \
|
|
75
|
+
"#{escape(entry[:operator])} <code>#{escape(entry[:digest])}</code>: #{escape(entry[:reason])}</li>"
|
|
76
|
+
end
|
|
77
|
+
"<h2>Inline suppressions (#{items.size}, outside the score)</h2><ul>#{items.join}</ul>"
|
|
78
|
+
end
|
|
79
|
+
|
|
69
80
|
def summary(report, data)
|
|
70
81
|
seed = data.dig(:config, :seed)
|
|
71
82
|
seed_note = seed ? ", seed #{seed}" : ""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# The original and replacement text of one mutant, and the line it
|
|
7
|
+
# changes, for compact one-line output.
|
|
8
|
+
module MutationText
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def line(result)
|
|
12
|
+
result.subject.line_of(sorted_edits(result).first.start_byte)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def original(result)
|
|
16
|
+
join(result) { |edit| result.subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def replacement(result)
|
|
20
|
+
join(result, &:replacement)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sorted_edits(result)
|
|
24
|
+
result.mutation.edits.sort_by(&:start_byte)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Whole-body mutants carry a long text; one line keeps the output compact.
|
|
28
|
+
def join(result, &pick)
|
|
29
|
+
text = sorted_edits(result).map { |edit| pick.call(edit).to_s.dup.force_encoding(Encoding::UTF_8).scrub }
|
|
30
|
+
.join(" | ")
|
|
31
|
+
flat = text.gsub(/\s*\n\s*/, " ").strip
|
|
32
|
+
flat.length > 60 ? "#{flat[0, 57]}..." : flat
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# The footer under a run that left survivors: how to look at one, how to
|
|
7
|
+
# accept one, and how to hand the run to an agent. Silent when nothing
|
|
8
|
+
# survived, so a clean run stays short.
|
|
9
|
+
class NextSteps
|
|
10
|
+
def initialize(out, palette)
|
|
11
|
+
@out = out
|
|
12
|
+
@palette = palette
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(report)
|
|
16
|
+
survivor = report.unignored_survivors.first
|
|
17
|
+
return unless survivor
|
|
18
|
+
|
|
19
|
+
@out.puts "\n#{@palette.paint(:bold, 'Next steps:')}"
|
|
20
|
+
seed = " --seed #{report.seed}" if report.seed
|
|
21
|
+
@out.puts " replay one survivor: saboteur run --id #{survivor.digest}#{seed}"
|
|
22
|
+
@out.puts " (add this run's own --config, --since, --subject, --test and source options)"
|
|
23
|
+
@out.puts " accept an equivalent one: add to your ignore file (c.ignore_file, default #{Config::IGNORE_FILE}):"
|
|
24
|
+
entry = %(i.mutation "#{survivor.digest}", subject: "#{survivor.subject.id}", reason: "why it is equivalent")
|
|
25
|
+
@out.puts " #{entry}"
|
|
26
|
+
@out.puts " let an agent close the gaps: saboteur run --agent, with the same options"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# Wraps text in ANSI colour codes when colour is on, and returns it
|
|
7
|
+
# unchanged when it is off. Colour is on only when asked for or when the
|
|
8
|
+
# output is a terminal and NO_COLOR is not set (https://no-color.org).
|
|
9
|
+
class Palette
|
|
10
|
+
CODES = { red: 31, green: 32, yellow: 33, magenta: 35, cyan: 36, dim: 2, bold: 1 }.freeze
|
|
11
|
+
|
|
12
|
+
# mode: :auto, :always or :never.
|
|
13
|
+
def self.for(out, mode: :auto, environment: ENV)
|
|
14
|
+
enabled = case mode
|
|
15
|
+
when :always then true
|
|
16
|
+
when :never then false
|
|
17
|
+
else out.respond_to?(:tty?) && out.tty? && environment["NO_COLOR"].to_s.empty?
|
|
18
|
+
end
|
|
19
|
+
new(enabled: enabled)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(enabled:)
|
|
23
|
+
@enabled = enabled
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def enabled?
|
|
27
|
+
@enabled
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def paint(color, text)
|
|
31
|
+
return text unless @enabled
|
|
32
|
+
|
|
33
|
+
"\e[#{CODES.fetch(color)}m#{text}\e[0m"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require_relative "agent_incomplete"
|
|
9
|
+
require_relative "history_record"
|
|
10
|
+
|
|
11
|
+
module Saboteur
|
|
12
|
+
module Reporter
|
|
13
|
+
# Opt-in append-only score observations, not a baseline or a CI gate.
|
|
14
|
+
class ScoreHistory
|
|
15
|
+
DIFFERENT_RUN = "score history: change unavailable (different selection/inventory or incomplete run)"
|
|
16
|
+
DIFFERENT_POLICY = "score history: change unavailable (different record version or operator policy)"
|
|
17
|
+
MISSING_SCORE = "score history: change unavailable (no tested mutants)"
|
|
18
|
+
MAX_RECORD_BYTES = 64 * 1024
|
|
19
|
+
OPEN_FLAGS = File::RDWR | File::CREAT | File::NONBLOCK | File::NOFOLLOW
|
|
20
|
+
|
|
21
|
+
def initialize(path)
|
|
22
|
+
@path = path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def append(report, head:, scope:)
|
|
26
|
+
record = build(report, head, scope)
|
|
27
|
+
HistoryRecord.validate(record)
|
|
28
|
+
line = "#{JSON.generate(record)}\n"
|
|
29
|
+
raise Error, "score history record exceeds byte limit" if line.bytesize > MAX_RECORD_BYTES
|
|
30
|
+
|
|
31
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
32
|
+
File.open(@path, OPEN_FLAGS, 0o600) do |file|
|
|
33
|
+
regular_file(file)
|
|
34
|
+
lock(file, File::LOCK_EX)
|
|
35
|
+
|
|
36
|
+
previous = previous_record(file)
|
|
37
|
+
file.seek(0, IO::SEEK_END)
|
|
38
|
+
file.write(line)
|
|
39
|
+
file.flush
|
|
40
|
+
change(previous, record)
|
|
41
|
+
end
|
|
42
|
+
rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError => e
|
|
43
|
+
raise Error, "score history was not recorded: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_existing
|
|
47
|
+
return unless File.exist?(@path) || File.symlink?(@path)
|
|
48
|
+
|
|
49
|
+
File.open(@path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) do |file|
|
|
50
|
+
regular_file(file)
|
|
51
|
+
lock(file, File::LOCK_SH)
|
|
52
|
+
|
|
53
|
+
previous_record(file)
|
|
54
|
+
end
|
|
55
|
+
rescue SystemCallError, IOError => e
|
|
56
|
+
raise Error, "score history cannot be read: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build(report, head, scope)
|
|
62
|
+
{ "version" => 2, "date" => Time.now.utc.iso8601, "commit" => head,
|
|
63
|
+
"selection" => Digest::SHA256.hexdigest(JSON.generate(scope.sort_by { |key, _| key.to_s }.to_h)),
|
|
64
|
+
"inventory" => Digest::SHA256.hexdigest(JSON.generate(report.results.map(&:digest).sort)),
|
|
65
|
+
"score" => overall_score(report), "coveredScore" => report.score,
|
|
66
|
+
"counts" => report.counts.transform_keys(&:to_s),
|
|
67
|
+
"breakdown" => report.score_breakdown.to_h.transform_keys(&:to_s),
|
|
68
|
+
"incomplete" => incompleteness(report), "operatorPolicy" => report.operator_policy }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def overall_score(report)
|
|
72
|
+
score = report.score_breakdown
|
|
73
|
+
missing = report.results.count { |result| result.status == :skipped && result.reason == "no coverage" }
|
|
74
|
+
denominator = score.tested + missing
|
|
75
|
+
denominator.zero? ? nil : score.detected.fdiv(denominator)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def incompleteness(report)
|
|
79
|
+
reasons = Agent::Incomplete.reasons(report).map { |reason| reason[:reason] }
|
|
80
|
+
reasons << "baseline_incomplete" if report.run_evidence && !report.run_evidence["complete"]
|
|
81
|
+
reasons << "omissions" unless report.omissions.empty?
|
|
82
|
+
reasons.uniq
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def lock(file, mode)
|
|
86
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
|
87
|
+
until file.flock(mode | File::LOCK_NB)
|
|
88
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
89
|
+
raise Error,
|
|
90
|
+
"score history lock was not acquired"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sleep 0.01
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def regular_file(file)
|
|
98
|
+
raise Error, "score history must be a regular file" unless file.stat.file?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def previous_record(file)
|
|
102
|
+
while (line = file.gets(MAX_RECORD_BYTES + 1))
|
|
103
|
+
raise Error, "score history record exceeds byte limit" if line.bytesize > MAX_RECORD_BYTES
|
|
104
|
+
raise Error, "score history contains a truncated record" unless line.end_with?("\n")
|
|
105
|
+
|
|
106
|
+
previous = HistoryRecord.parse(line)
|
|
107
|
+
end
|
|
108
|
+
previous
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Only one record version and one non-null operator policy compare:
|
|
112
|
+
# scores never mix across v1/v2 or across policy configurations.
|
|
113
|
+
# A non-null previous policy forces a non-null equal current one, so
|
|
114
|
+
# only the previous record needs the null guard.
|
|
115
|
+
def comparable_policy?(previous, current)
|
|
116
|
+
previous["version"] == current["version"] &&
|
|
117
|
+
!previous["operatorPolicy"].nil? &&
|
|
118
|
+
previous["operatorPolicy"] == current["operatorPolicy"]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def change(previous, current)
|
|
122
|
+
return "score history: first record" unless previous
|
|
123
|
+
return DIFFERENT_RUN unless
|
|
124
|
+
previous["selection"] == current["selection"] && previous["inventory"] == current["inventory"] &&
|
|
125
|
+
previous["incomplete"].empty? && current["incomplete"].empty?
|
|
126
|
+
return DIFFERENT_POLICY unless comparable_policy?(previous, current)
|
|
127
|
+
return MISSING_SCORE if
|
|
128
|
+
previous["coveredScore"].nil? || current["coveredScore"].nil?
|
|
129
|
+
|
|
130
|
+
delta = (current["coveredScore"] - previous["coveredScore"]) * 100
|
|
131
|
+
format("score history: covered score change %+.2f percentage points (same configured selection)", delta)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|