saboteur 0.3.0 → 0.5.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -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 +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- 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 +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -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 +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- 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 +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- 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_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- 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 +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- 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 +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- 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 +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "prism"
|
|
5
|
+
require_relative "../inline_suppression"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
class Engine
|
|
@@ -12,7 +13,7 @@ module Saboteur
|
|
|
12
13
|
def self.build_sources(config)
|
|
13
14
|
root = File.realpath(config.root)
|
|
14
15
|
config["sources"].map do |entry|
|
|
15
|
-
next MutationSource::Builtin.new(arid: config["arid"]) if entry == "builtin"
|
|
16
|
+
next MutationSource::Builtin.new(arid: config["arid"], operators: config["operators"]) if entry == "builtin"
|
|
16
17
|
|
|
17
18
|
file, const = entry.split(":", 2)
|
|
18
19
|
raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName" unless const
|
|
@@ -22,37 +23,88 @@ module Saboteur
|
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def initialize(sources, diagnostics)
|
|
26
|
+
def initialize(sources, diagnostics, hazard_guard: nil, bytecode: BytecodeEquivalence.new)
|
|
26
27
|
@sources = sources
|
|
27
28
|
@diagnostics = diagnostics
|
|
29
|
+
@hazard_guard = hazard_guard
|
|
30
|
+
@bytecode = bytecode
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
def for(subject)
|
|
31
|
-
|
|
34
|
+
unless parses?(subject.source)
|
|
35
|
+
@diagnostics.record_generation(subject, source: "subject", reason: "invalid_subject_syntax")
|
|
36
|
+
return []
|
|
37
|
+
end
|
|
32
38
|
|
|
33
39
|
seen = Set[subject.source]
|
|
34
|
-
list = @sources.flat_map { |source| source
|
|
40
|
+
list = @sources.flat_map { |source| proposals(source, subject) }.filter_map do |mutation|
|
|
35
41
|
candidate(subject, mutation, seen)
|
|
36
42
|
end
|
|
43
|
+
list = suppress_inline(list, subject)
|
|
44
|
+
list = suppress_hazards(list, subject)
|
|
37
45
|
list.sort_by.with_index { |(mutation, _), index| [mutation.coarse ? 0 : 1, index] }
|
|
38
46
|
end
|
|
39
47
|
|
|
40
48
|
private
|
|
41
49
|
|
|
50
|
+
def suppress_inline(list, subject)
|
|
51
|
+
directives = InlineSuppression.new(subject.file, subject.file_source)
|
|
52
|
+
list.reject do |mutation, _text|
|
|
53
|
+
directive = directives.reason(subject, mutation)
|
|
54
|
+
@diagnostics.record_inline_suppression(subject, mutation, directive) if directive
|
|
55
|
+
directive
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The hazard guard (issue #102) refuses mutants that would leave a
|
|
60
|
+
# destructive call running with altered inputs; each refusal is counted.
|
|
61
|
+
def suppress_hazards(list, subject)
|
|
62
|
+
return list unless @hazard_guard
|
|
63
|
+
|
|
64
|
+
kept, suppressed = @hazard_guard.partition(list, subject)
|
|
65
|
+
suppressed.each { |(mutation, _)| @diagnostics.record_hazard_suppression(subject, mutation) }
|
|
66
|
+
kept
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def proposals(source, subject)
|
|
70
|
+
proposals = source.mutations_for(subject)
|
|
71
|
+
if proposals.empty?
|
|
72
|
+
@diagnostics.record_generation(subject, source: source_name(source), reason: "empty_generation")
|
|
73
|
+
end
|
|
74
|
+
proposals
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def source_name(source)
|
|
78
|
+
name = source.name if source.respond_to?(:name)
|
|
79
|
+
name || "anonymous"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_drop(subject, mutation, reason)
|
|
83
|
+
@diagnostics.record_generation(subject, source: mutation.source, operator: mutation.operator, reason: reason)
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
42
87
|
def candidate(subject, mutation, seen)
|
|
43
88
|
text = subject.apply(mutation.edits)
|
|
44
|
-
return if
|
|
89
|
+
return record_drop(subject, mutation, "unchanged") if text == subject.source
|
|
90
|
+
return record_drop(subject, mutation, "duplicate") if seen.include?(text)
|
|
45
91
|
|
|
46
92
|
seen << text
|
|
47
93
|
unless parses?(text)
|
|
48
94
|
@diagnostics.count_invalid_mutation
|
|
49
|
-
return
|
|
95
|
+
return record_drop(subject, mutation, "invalid_syntax")
|
|
50
96
|
end
|
|
51
97
|
|
|
98
|
+
return record_drop(subject, mutation, "equivalent_bytecode") if equivalent?(subject, text)
|
|
99
|
+
|
|
52
100
|
[mutation, text]
|
|
53
101
|
rescue ArgumentError => e
|
|
54
102
|
@diagnostics.warn("#{subject.id}: dropped #{mutation.source} mutation: #{e.message}")
|
|
55
|
-
|
|
103
|
+
record_drop(subject, mutation, "invalid_edit")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def equivalent?(subject, text)
|
|
107
|
+
@bytecode.equivalent?(subject.source, text, frozen_string_literal: subject.frozen_literals)
|
|
56
108
|
end
|
|
57
109
|
|
|
58
110
|
def parses?(text)
|
|
@@ -15,22 +15,25 @@ module Saboteur
|
|
|
15
15
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
16
16
|
LOG = "coverage-build.log"
|
|
17
17
|
|
|
18
|
-
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
|
|
18
|
+
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, load_lines: {})
|
|
19
19
|
@config = config
|
|
20
20
|
@integration = integration
|
|
21
21
|
@hooks = hooks
|
|
22
22
|
@supervisor = supervisor
|
|
23
23
|
@diagnostics = diagnostics
|
|
24
|
+
@load_lines = load_lines
|
|
24
25
|
@root = File.realpath(config.root)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def fetch(files)
|
|
28
29
|
path = File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
|
|
29
30
|
cached = @config["cache"] ? Selection::CoverageMap.load(path) : nil
|
|
30
|
-
return cached if cached
|
|
31
|
+
return refuse_empty_map(cached, files) if cached
|
|
31
32
|
|
|
32
|
-
map = build(files)
|
|
33
|
+
map = refuse_empty_map(build(files), files)
|
|
33
34
|
map.save(path)
|
|
35
|
+
File.write(File.join(@config.path("cache_dir"), "preview.json"),
|
|
36
|
+
JSON.generate(path: path, fingerprint: RunFingerprint.new(config: @config).snapshot.digest))
|
|
34
37
|
map
|
|
35
38
|
end
|
|
36
39
|
|
|
@@ -52,18 +55,62 @@ module Saboteur
|
|
|
52
55
|
"Run through the saboteur executable and disable SimpleCov for the run."
|
|
53
56
|
end
|
|
54
57
|
|
|
55
|
-
@
|
|
58
|
+
scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
|
|
59
|
+
@diagnostics.inform("building coverage map (runs #{scope} once)")
|
|
56
60
|
# The suite child forked next inherits this heap; pack it first, to
|
|
57
61
|
# reduce later copying of shared pages.
|
|
58
62
|
Execution::ForkReadiness.prepare_heap
|
|
59
|
-
|
|
63
|
+
heartbeat = @diagnostics.progress_heartbeat("building coverage map").watch(log_path)
|
|
64
|
+
result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
|
|
65
|
+
record(writer, files)
|
|
66
|
+
end
|
|
67
|
+
map_from(result)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def map_from(result)
|
|
60
71
|
raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
|
|
61
72
|
raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
|
|
62
73
|
|
|
63
74
|
data = JSON.parse(result.payload)
|
|
64
75
|
raise Error, "coverage map build failed: #{data['error']}" if data["error"]
|
|
65
76
|
|
|
66
|
-
Selection::CoverageMap.from_json(result.payload)
|
|
77
|
+
with_parent_load_lines(Selection::CoverageMap.from_json(result.payload))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The parent's load lines merge into the freshly built map: the forked
|
|
81
|
+
# build child cannot inherit the parent's Coverage counters.
|
|
82
|
+
def with_parent_load_lines(map)
|
|
83
|
+
@load_lines.empty? ? map : map.with_load_lines(@load_lines)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A whole-suite build that records nothing - no line hit and no method
|
|
87
|
+
# call - in any included file fails the run (exit 3): usually no test
|
|
88
|
+
# in the suite exercises these files, or the setup hid the app from
|
|
89
|
+
# Coverage (Bootsnap's compile cache is the default Rails case). An
|
|
90
|
+
# all-endless-def file records method calls only and passes. Either way
|
|
91
|
+
# a run on a truly empty map would test nothing, and exit 0 would lie.
|
|
92
|
+
# With explicit tests (--test) an empty map is a legitimate outcome
|
|
93
|
+
# instead: the user narrowed the suite to specs that may not touch
|
|
94
|
+
# these sources, so the map passes with a warning. Applies to a cached
|
|
95
|
+
# map too, so a map cached before this check cannot keep a run green,
|
|
96
|
+
# and the guard runs before saving, so an empty map is never cached.
|
|
97
|
+
def refuse_empty_map(map, files)
|
|
98
|
+
return map if files.empty? || !map.empty?
|
|
99
|
+
|
|
100
|
+
if @config["tests"].empty?
|
|
101
|
+
raise Error, "Coverage recorded nothing in #{files.size} included files during a whole-suite " \
|
|
102
|
+
"build. No test in the suite may exercise these files, or the setup hid the app " \
|
|
103
|
+
"from Coverage#{bootsnap_note}. Run `saboteur doctor`."
|
|
104
|
+
end
|
|
105
|
+
@diagnostics.warn("Coverage recorded nothing in #{files.size} included files; " \
|
|
106
|
+
"the selected specs may not exercise them.")
|
|
107
|
+
map
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Bootsnap is named only when it is loaded: blaming a library the app
|
|
111
|
+
# does not use sends the reader to the wrong place.
|
|
112
|
+
def bootsnap_note
|
|
113
|
+
" (Bootsnap's compile cache is the default Rails case)" if defined?(::Bootsnap)
|
|
67
114
|
end
|
|
68
115
|
|
|
69
116
|
def died(status)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Non-Ruby files a test run can read and that change its outcome: Rails
|
|
7
|
+
# fixtures, schema dumps and database/environment configuration, plus
|
|
8
|
+
# anything listed under `fingerprint_extra`. Ruby files are tracked
|
|
9
|
+
# elsewhere; these have no .rb extension, so the Ruby walk misses them.
|
|
10
|
+
# An absent pattern simply matches nothing, so plain gems are unaffected.
|
|
11
|
+
module DataInputs
|
|
12
|
+
GLOBS = %w[
|
|
13
|
+
spec/fixtures/**/*
|
|
14
|
+
spec/**/fixtures/**/*
|
|
15
|
+
test/fixtures/**/*
|
|
16
|
+
db/structure.sql
|
|
17
|
+
db/schema.rb
|
|
18
|
+
config/database.yml
|
|
19
|
+
config/database.yml.erb
|
|
20
|
+
config/*.yml
|
|
21
|
+
config/environments/*.yml
|
|
22
|
+
config/credentials/*.yml.enc
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# True when the absolute path is one of the declared inputs, whether or
|
|
26
|
+
# not the file exists yet. A run must never write there.
|
|
27
|
+
def self.match?(root, path, extra: [])
|
|
28
|
+
return false unless path.start_with?("#{root}/")
|
|
29
|
+
|
|
30
|
+
relative = path.delete_prefix("#{root}/")
|
|
31
|
+
flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
32
|
+
(GLOBS + extra).any? { |pattern| File.fnmatch?(pattern, relative, flags) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Sorted absolute paths of regular files (symlinks followed) under root.
|
|
36
|
+
def self.files(root, extra: [])
|
|
37
|
+
(GLOBS + extra).flat_map { |pattern| Dir.glob(pattern, File::FNM_DOTMATCH, base: root) }
|
|
38
|
+
.map { |relative| File.join(root, relative) }
|
|
39
|
+
.select { |path| File.file?(path) }.uniq.sort
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -1,19 +1,90 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
class Engine
|
|
6
8
|
# What a run has to say besides results: warnings for the report, the
|
|
7
9
|
# count of mutations dropped for not parsing, and progress marks.
|
|
8
10
|
class Diagnostics
|
|
11
|
+
# See progress_heartbeat. Time-gated itself, so the supervisor can
|
|
12
|
+
# call tick on every poll. A phase whose child writes "N/M examples
|
|
13
|
+
# finished" lines to a log (the baseline and coverage children do) can
|
|
14
|
+
# be watched: the heartbeat then adds the count, the percentage and an
|
|
15
|
+
# estimate of the time left to its elapsed-time line.
|
|
16
|
+
class ProgressHeartbeat
|
|
17
|
+
COUNT = %r{(\d+)/([1-9]\d*) examples finished}
|
|
18
|
+
TAIL_BYTES = 4096
|
|
19
|
+
|
|
20
|
+
def initialize(diagnostics, label, interval: 30)
|
|
21
|
+
@diagnostics = diagnostics
|
|
22
|
+
@label = label
|
|
23
|
+
@interval = interval
|
|
24
|
+
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
@last_print = @started
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Reads progress from the end of this log on each print. Returns self.
|
|
29
|
+
def watch(log_path)
|
|
30
|
+
@log = log_path
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def tick
|
|
35
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
36
|
+
return if now - @last_print < @interval
|
|
37
|
+
|
|
38
|
+
@last_print = now
|
|
39
|
+
elapsed = now - @started
|
|
40
|
+
@diagnostics.inform("#{@label}: #{elapsed.round} s elapsed#{progress_note(elapsed)}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# ", 250/1000 examples (25 %), about 90 s left", or "" when the log has
|
|
46
|
+
# no count yet. The estimate assumes examples take equally long, so it
|
|
47
|
+
# is rough: slow examples late in the order make it too low.
|
|
48
|
+
def progress_note(elapsed)
|
|
49
|
+
done, total = examples_finished
|
|
50
|
+
return "" if done.nil?
|
|
51
|
+
|
|
52
|
+
note = ", #{done}/#{total} examples (#{(done * 100.0 / total).floor} %)"
|
|
53
|
+
return note if done.zero? || done >= total
|
|
54
|
+
|
|
55
|
+
"#{note}, about #{(elapsed * (total - done) / done).ceil} s left"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def examples_finished
|
|
59
|
+
return unless @log
|
|
60
|
+
|
|
61
|
+
tail = File.read(@log, TAIL_BYTES, [File.size(@log) - TAIL_BYTES, 0].max).to_s.scrub
|
|
62
|
+
tail.scan(COUNT).last&.map(&:to_i)
|
|
63
|
+
rescue SystemCallError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
9
68
|
MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
|
|
69
|
+
LEGEND = ". killed S survived T timeout L resource limit E error"
|
|
70
|
+
LINE_WIDTH = 50
|
|
10
71
|
|
|
11
|
-
|
|
72
|
+
GenerationEvent = Data.define(:file, :subject, :source, :operator, :reason)
|
|
73
|
+
Omission = Data.define(:subject, :count, :limit)
|
|
74
|
+
DiscoveryFailure = Data.define(:file, :reason)
|
|
75
|
+
|
|
76
|
+
attr_reader :warnings, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
|
|
77
|
+
:hazard_suppressions, :inline_suppressions
|
|
12
78
|
|
|
13
79
|
def initialize(err)
|
|
14
80
|
@err = err
|
|
15
81
|
@warnings = []
|
|
16
82
|
@invalid_mutations = 0
|
|
83
|
+
@generation_events = []
|
|
84
|
+
@omissions = []
|
|
85
|
+
@discovery_failures = []
|
|
86
|
+
@hazard_suppressions = 0
|
|
87
|
+
@inline_suppressions = []
|
|
17
88
|
end
|
|
18
89
|
|
|
19
90
|
def warn(message)
|
|
@@ -24,14 +95,117 @@ module Saboteur
|
|
|
24
95
|
@invalid_mutations += 1
|
|
25
96
|
end
|
|
26
97
|
|
|
98
|
+
def record_generation(subject, source:, reason:, operator: nil)
|
|
99
|
+
values = [subject.file, subject.id, source, operator, reason].map do |value|
|
|
100
|
+
value.nil? ? nil : value.to_s.dup.freeze
|
|
101
|
+
end
|
|
102
|
+
@generation_events << GenerationEvent.new(*values)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def record_discovery_failure(file, reason:)
|
|
106
|
+
@discovery_failures << DiscoveryFailure.new(file: file.to_s.dup.freeze, reason: reason.to_s.dup.freeze)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A mutant the hazard guard refused to generate (issue #102): counted
|
|
110
|
+
# for the summary and recorded like any other generation event.
|
|
111
|
+
def record_hazard_suppression(subject, mutation)
|
|
112
|
+
@hazard_suppressions += 1
|
|
113
|
+
record_generation(subject, source: "hazard_guard", operator: mutation.operator, reason: "hazardous")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def record_inline_suppression(subject, mutation, directive)
|
|
117
|
+
@inline_suppressions << {
|
|
118
|
+
file: subject.file, line: directive.line, subject: subject.id,
|
|
119
|
+
operator: mutation.operator, reason: directive.reason,
|
|
120
|
+
digest: mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def record_omission(subject, count:, limit:)
|
|
125
|
+
@omissions << Omission.new(subject: subject.id.dup.freeze, count: count, limit: limit)
|
|
126
|
+
end
|
|
127
|
+
|
|
27
128
|
def inform(message)
|
|
28
129
|
@err.puts("saboteur: #{message}")
|
|
29
130
|
end
|
|
30
131
|
|
|
132
|
+
# A heartbeat for a long phase: the supervisor's wait loop calls tick
|
|
133
|
+
# in the parent after the fork, so no extra thread is ever alive at
|
|
134
|
+
# fork time. Prints "<label>: N s elapsed" about every interval
|
|
135
|
+
# seconds, so a long phase does not look stuck.
|
|
136
|
+
def progress_heartbeat(label, interval: 30)
|
|
137
|
+
ProgressHeartbeat.new(self, label, interval: interval)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Call before the workers fork. total is the number of mutants the run
|
|
141
|
+
# may execute; mutants that are skipped or reused never report progress,
|
|
142
|
+
# so the counter can end below total and the ETA errs on the long side.
|
|
143
|
+
# The count lives in one file the workers inherit, so every worker adds
|
|
144
|
+
# to the same number.
|
|
145
|
+
def start_progress(total)
|
|
146
|
+
@progress_total = total
|
|
147
|
+
@progress_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
148
|
+
@progress_file = Tempfile.new("saboteur-progress")
|
|
149
|
+
@progress_file.binmode
|
|
150
|
+
@progress_file.sync = true
|
|
151
|
+
@err.puts("saboteur: progress #{LEGEND}") unless progress_on_terminal?
|
|
152
|
+
end
|
|
153
|
+
|
|
31
154
|
def progress(result)
|
|
32
|
-
|
|
155
|
+
mark = MARKS.fetch(result.status, "?")
|
|
156
|
+
return plain_progress(mark) unless @progress_file
|
|
157
|
+
|
|
158
|
+
done = record_progress(mark)
|
|
159
|
+
progress_on_terminal? ? terminal_progress(done) : ci_progress(mark, done)
|
|
160
|
+
@err.flush
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def finish_progress
|
|
164
|
+
@err.puts if @progress_file && (progress_on_terminal? || @progress_file.size % LINE_WIDTH != 0)
|
|
165
|
+
@progress_file&.close!
|
|
166
|
+
@progress_file = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def progress_summary(done)
|
|
170
|
+
return "#{done}/#{@progress_total}" unless @progress_total.to_i.positive?
|
|
171
|
+
|
|
172
|
+
percent = (done * 100.0 / @progress_total).floor
|
|
173
|
+
"#{done}/#{@progress_total} (#{percent}%)#{eta_text(done)}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def plain_progress(mark)
|
|
179
|
+
@err.print(mark)
|
|
33
180
|
@err.flush
|
|
34
181
|
end
|
|
182
|
+
|
|
183
|
+
def record_progress(mark)
|
|
184
|
+
@progress_file.write(mark)
|
|
185
|
+
@progress_file.size
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def progress_on_terminal?
|
|
189
|
+
@err.respond_to?(:tty?) && @err.tty?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def terminal_progress(done)
|
|
193
|
+
@err.print("\r\e[Ksaboteur: #{progress_summary(done)}")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def ci_progress(mark, done)
|
|
197
|
+
@err.print(mark)
|
|
198
|
+
@err.print(" #{progress_summary(done)}\n") if (done % LINE_WIDTH).zero?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def eta_text(done)
|
|
202
|
+
remaining = @progress_total - done
|
|
203
|
+
return "" if remaining <= 0 || done.zero?
|
|
204
|
+
|
|
205
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @progress_started
|
|
206
|
+
seconds = (elapsed / done * remaining).round
|
|
207
|
+
" eta #{seconds / 60}m#{format('%02d', seconds % 60)}s"
|
|
208
|
+
end
|
|
35
209
|
end
|
|
36
210
|
end
|
|
37
211
|
end
|
|
@@ -20,6 +20,24 @@ module Saboteur
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# The changed lines a mutant touches, sorted. A statement that spans
|
|
24
|
+
# several lines is mutated as one piece and reported at its first line,
|
|
25
|
+
# which can sit above the line that changed; this names the changed line.
|
|
26
|
+
def changed_lines_for(subject, mutation)
|
|
27
|
+
changed = @changed_lines.fetch(subject.file, Set.new)
|
|
28
|
+
mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Distance to the nearest changed line in this file. Unknown outside
|
|
32
|
+
# the diff is nil, not a made-up nearby location.
|
|
33
|
+
def distance_for(subject, mutation)
|
|
34
|
+
changed = @changed_lines.fetch(subject.file, Set.new)
|
|
35
|
+
mutation.edits.map do |edit|
|
|
36
|
+
first, last = edited_lines(subject, edit).minmax
|
|
37
|
+
changed.map { |line| (line - line.clamp(first, last)).abs }.min
|
|
38
|
+
end.min
|
|
39
|
+
end
|
|
40
|
+
|
|
23
41
|
private
|
|
24
42
|
|
|
25
43
|
def edited_lines(subject, edit)
|
|
@@ -8,19 +8,37 @@ module Saboteur
|
|
|
8
8
|
# plain data; the parent rebuilds them against its own copy of the same
|
|
9
9
|
# candidate list, so results look the same either way.
|
|
10
10
|
class Distribution
|
|
11
|
-
|
|
11
|
+
# After run: the subjects a stop_after callable left unrun (always empty
|
|
12
|
+
# without one).
|
|
13
|
+
attr_reader :unreached
|
|
14
|
+
|
|
15
|
+
# stop_after: nil, or a callable that gets one subject's results and
|
|
16
|
+
# returns true to stop before the next subject (--fail-fast). It only
|
|
17
|
+
# works on one process: parallel workers have already started the
|
|
18
|
+
# other subjects.
|
|
19
|
+
def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil, after_subject: nil,
|
|
20
|
+
stop_after: nil)
|
|
12
21
|
@jobs = jobs
|
|
22
|
+
@stop_after = stop_after
|
|
23
|
+
@unreached = []
|
|
13
24
|
@on_start = on_start
|
|
14
25
|
@scheduler = scheduler
|
|
15
26
|
@diagnostics = diagnostics
|
|
16
27
|
@cache = cache
|
|
28
|
+
@after_subject = after_subject
|
|
17
29
|
end
|
|
18
30
|
|
|
19
31
|
# lists: each subject's [[mutation, text]] candidates, in subject order.
|
|
20
32
|
def run(subjects, lists)
|
|
21
|
-
return subjects
|
|
33
|
+
return run_serially(subjects, lists) if @jobs <= 1
|
|
22
34
|
|
|
23
|
-
|
|
35
|
+
# The watcher runs as each subject's row arrives, before the pool
|
|
36
|
+
# hands out more work, so a tripped canary stops the queue (jobs>1
|
|
37
|
+
# used to check only after the whole batch returned, issue #102).
|
|
38
|
+
arrival = lambda do |i, row|
|
|
39
|
+
@after_subject&.call(subjects[i], decode(row, subjects[i], lists[i]))
|
|
40
|
+
end
|
|
41
|
+
pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start, on_result: arrival)
|
|
24
42
|
rows = pool.map(subjects.each_index.to_a, weights: lists.map(&:size)) do |i|
|
|
25
43
|
work(subjects[i], lists[i])
|
|
26
44
|
end
|
|
@@ -29,6 +47,20 @@ module Saboteur
|
|
|
29
47
|
|
|
30
48
|
private
|
|
31
49
|
|
|
50
|
+
def run_serially(subjects, lists)
|
|
51
|
+
results = []
|
|
52
|
+
subjects.zip(lists).each_with_index do |(subject, list), index|
|
|
53
|
+
subject_results = @scheduler.call(subject, list)
|
|
54
|
+
results.concat(subject_results)
|
|
55
|
+
@after_subject&.call(subject, subject_results)
|
|
56
|
+
next unless @stop_after&.call(subject_results)
|
|
57
|
+
|
|
58
|
+
@unreached = subjects.drop(index + 1)
|
|
59
|
+
break
|
|
60
|
+
end
|
|
61
|
+
results
|
|
62
|
+
end
|
|
63
|
+
|
|
32
64
|
# Runs in a worker.
|
|
33
65
|
def work(subject, list)
|
|
34
66
|
before = @diagnostics.warnings.size
|
|
@@ -43,29 +75,38 @@ module Saboteur
|
|
|
43
75
|
"status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
|
|
44
76
|
"killed_by" => result.killed_by, "covered_by" => result.covered_by,
|
|
45
77
|
"kill_cause" => result.kill_cause, "reused" => result.reused?,
|
|
46
|
-
"rejected_kill" => result.rejected_kill
|
|
78
|
+
"rejected_kill" => result.rejected_kill?, "executed" => result.executed
|
|
47
79
|
}
|
|
48
80
|
end
|
|
49
81
|
|
|
50
82
|
def rebuild(row, subject, list)
|
|
51
|
-
|
|
83
|
+
if row["error"]
|
|
84
|
+
@diagnostics.warn("#{subject.id}: #{row['error']}")
|
|
85
|
+
return failed(row["error"], subject, list)
|
|
86
|
+
end
|
|
52
87
|
|
|
53
88
|
@diagnostics.warnings.concat(row["warnings"])
|
|
89
|
+
decode(row, subject, list).each { |result| @cache&.record(result) unless result.reused? }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Turns a worker row back into results, without diagnostics or caching:
|
|
93
|
+
# used both at result arrival (the canary) and at rebuild time.
|
|
94
|
+
def decode(row, subject, list)
|
|
95
|
+
return failed(row["error"], subject, list) if row["error"]
|
|
96
|
+
|
|
54
97
|
row["results"].map do |data|
|
|
55
98
|
mutation, text = list.fetch(data["position"])
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
result
|
|
99
|
+
MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
|
|
100
|
+
mutated_source: text, reason: data["reason"], runtime: data["runtime"],
|
|
101
|
+
killed_by: data["killed_by"], covered_by: data["covered_by"],
|
|
102
|
+
kill_cause: data["kill_cause"], reused: data["reused"],
|
|
103
|
+
executed: data["executed"],
|
|
104
|
+
rejected_kill: data.fetch("rejected_kill", false))
|
|
63
105
|
end
|
|
64
106
|
end
|
|
65
107
|
|
|
66
108
|
# The worker died (or raised) while running this subject.
|
|
67
109
|
def failed(message, subject, list)
|
|
68
|
-
@diagnostics.warn("#{subject.id}: #{message}")
|
|
69
110
|
list.map do |mutation, text|
|
|
70
111
|
MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: text,
|
|
71
112
|
reason: message)
|
|
@@ -11,13 +11,16 @@ module Saboteur
|
|
|
11
11
|
# Stryker's history files.
|
|
12
12
|
#
|
|
13
13
|
# A verdict is reused only while everything it was computed from is the
|
|
14
|
-
# same: the method's source, the
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# same: the method's source, the whole file the method lives in (so a
|
|
15
|
+
# constant or helper changed elsewhere in that file is seen), the mutant,
|
|
16
|
+
# the set of covering tests, the content of those tests' files, the
|
|
17
|
+
# lockfile, the run's inputs (config, saboteur version). Only killed, survived
|
|
18
|
+
# and timeout are stored;
|
|
17
19
|
# errors and skips always run again.
|
|
18
20
|
#
|
|
19
|
-
# Known hole, the reason this is opt-in: a change in
|
|
20
|
-
#
|
|
21
|
+
# Known hole, the reason this is opt-in: a change in another file that
|
|
22
|
+
# this method uses (a method it calls, a constant defined elsewhere) is
|
|
23
|
+
# not seen, so a stored verdict may be stale after it.
|
|
21
24
|
class ResultCache
|
|
22
25
|
FORMAT = 1
|
|
23
26
|
STORED = %i[killed survived timeout].freeze
|
|
@@ -75,6 +78,8 @@ module Saboteur
|
|
|
75
78
|
def fingerprint(subject, tests)
|
|
76
79
|
digest = Digest::SHA256.new
|
|
77
80
|
digest << VERSION << "\0" << @inputs << "\0" << @suite_digest << "\0" << subject.source << "\0"
|
|
81
|
+
# A hex digest has a fixed width, so it needs no separator after it.
|
|
82
|
+
digest << Digest::SHA256.hexdigest(subject.file_source)
|
|
78
83
|
tests.sort.each { |test| digest << test << "\0" << file_digest(test_file(test)) << "\0" }
|
|
79
84
|
LOCKFILES.each { |name| digest << file_digest(File.join(@root, name)) << "\0" }
|
|
80
85
|
digest.hexdigest
|