saboteur 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +44 -6
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +116 -0
- data/lib/saboteur/baseline/run_evidence.rb +2 -1
- data/lib/saboteur/cli/agent_provenance.rb +59 -0
- data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
- data/lib/saboteur/cli/doctor_command.rb +41 -0
- data/lib/saboteur/cli/error_cause.rb +40 -0
- data/lib/saboteur/cli/execution_options.rb +46 -0
- data/lib/saboteur/cli/exit_status.rb +12 -14
- data/lib/saboteur/cli/explicit_file_options.rb +21 -0
- data/lib/saboteur/cli/explicit_sources.rb +46 -0
- data/lib/saboteur/cli/failure_output.rb +35 -0
- data/lib/saboteur/cli/help_command.rb +23 -0
- data/lib/saboteur/cli/matrix_option.rb +17 -0
- data/lib/saboteur/cli/output_options.rb +27 -0
- data/lib/saboteur/cli/presets.rb +41 -1
- data/lib/saboteur/cli/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +6 -1
- data/lib/saboteur/cli/run_command.rb +33 -0
- data/lib/saboteur/cli/run_output.rb +38 -0
- data/lib/saboteur/cli/run_reporting.rb +6 -5
- data/lib/saboteur/cli/run_validation.rb +54 -0
- data/lib/saboteur/cli/source_set.rb +0 -18
- data/lib/saboteur/cli.rb +29 -305
- data/lib/saboteur/config.rb +76 -7
- data/lib/saboteur/config_file.rb +12 -8
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +24 -5
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +6 -4
- data/lib/saboteur/doctor.rb +10 -0
- data/lib/saboteur/engine/baseline_guard.rb +118 -15
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +16 -5
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
- data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
- data/lib/saboteur/engine/coverage_partial.rb +48 -0
- data/lib/saboteur/engine/coverage_parts.rb +50 -0
- data/lib/saboteur/engine/coverage_shadow.rb +92 -0
- data/lib/saboteur/engine/coverage_store.rb +296 -21
- data/lib/saboteur/engine/diagnostics.rb +24 -7
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +4 -1
- data/lib/saboteur/engine/load_coverage.rb +72 -8
- data/lib/saboteur/engine/partial_invalidation.rb +115 -0
- data/lib/saboteur/engine/partial_merge.rb +40 -0
- data/lib/saboteur/engine/pipelines.rb +11 -3
- data/lib/saboteur/engine/reference_specs.rb +71 -0
- data/lib/saboteur/engine/result_cache.rb +68 -5
- data/lib/saboteur/engine/resume_manifest.rb +110 -0
- data/lib/saboteur/engine/run_budget.rb +10 -7
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +26 -5
- data/lib/saboteur/engine/scheduler.rb +33 -25
- data/lib/saboteur/engine/selection_announcement.rb +9 -3
- data/lib/saboteur/engine/shadow_comparison.rb +121 -0
- data/lib/saboteur/engine/shadow_run.rb +38 -0
- data/lib/saboteur/engine/subject_selection.rb +144 -64
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +186 -32
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +44 -11
- data/lib/saboteur/environment_hints.rb +55 -0
- data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
- data/lib/saboteur/execution/external_runner.rb +123 -1
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
- data/lib/saboteur/execution/marker_sweep.rb +2 -1
- data/lib/saboteur/execution/process_table.rb +7 -3
- data/lib/saboteur/execution/reevaluator.rb +7 -0
- data/lib/saboteur/execution/sandbox.rb +39 -20
- data/lib/saboteur/execution/seatbelt.rb +10 -3
- data/lib/saboteur/execution/supervisor.rb +42 -8
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
- data/lib/saboteur/gates.rb +121 -28
- data/lib/saboteur/install.rb +6 -1
- data/lib/saboteur/integration/base.rb +19 -2
- data/lib/saboteur/integration/rspec.rb +145 -18
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +33 -21
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
- data/lib/saboteur/mutation_source/jump_value.rb +59 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
- data/lib/saboteur/mutation_source/operator_examples.rb +6 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +6 -0
- data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
- data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
- data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
- data/lib/saboteur/mutation_source/when_removal.rb +51 -0
- data/lib/saboteur/preview.rb +13 -0
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
- data/lib/saboteur/reporter/agent_summary.rb +16 -10
- data/lib/saboteur/reporter/review_comments.rb +151 -0
- data/lib/saboteur/reporter/safe_out.rb +32 -0
- data/lib/saboteur/reporter/stryker.rb +8 -6
- data/lib/saboteur/reporter/survivor_classification.rb +41 -3
- data/lib/saboteur/reporter/survivor_hints.rb +5 -0
- data/lib/saboteur/reporter/terminal.rb +45 -5
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +150 -9
- data/lib/saboteur/selection/external_test_map.rb +4 -0
- data/lib/saboteur/selection/git_diff.rb +24 -2
- data/lib/saboteur/selection/layout_only_lines.rb +45 -1
- data/lib/saboteur/selection/recorder.rb +52 -22
- data/lib/saboteur/selection/trace_recorder.rb +83 -0
- data/lib/saboteur/selection/untracked_changes.rb +1 -2
- data/lib/saboteur/subject.rb +16 -0
- data/lib/saboteur/subject_finder.rb +91 -6
- data/lib/saboteur/text.rb +31 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +7 -2
- data/lib/saboteur.rb +9 -0
- metadata +48 -16
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require_relative "../text"
|
|
9
|
+
|
|
10
|
+
module Saboteur
|
|
11
|
+
class Engine
|
|
12
|
+
# The inputs a coverage map was built from, saved beside it as
|
|
13
|
+
# inputs-<map key>.json: relative path => content digest, plus the
|
|
14
|
+
# settings digest, the runtime identity (RunFingerprint.runtime_identity
|
|
15
|
+
# and the map format) and the project root.
|
|
16
|
+
#
|
|
17
|
+
# Diagnostic only (#46). A restored cache is untrusted data, so a
|
|
18
|
+
# snapshot is accepted only when everything checks: size and entry caps,
|
|
19
|
+
# an exact key set, every field's type and shape, the map key in the file
|
|
20
|
+
# name, the project root, and the map file next to it with the digest
|
|
21
|
+
# recorded when the pair was written. Anything else reads as no snapshot.
|
|
22
|
+
# It still must not be the authority for reusing part of a map: it
|
|
23
|
+
# describes the inputs, not what a test read from them.
|
|
24
|
+
#
|
|
25
|
+
# The pair is written map first, snapshot last, through a temp file and a
|
|
26
|
+
# rename, so a snapshot never names a map that is not on disk.
|
|
27
|
+
module CoverageInputsSnapshot
|
|
28
|
+
FORMAT = 2
|
|
29
|
+
PREFIX = "inputs-"
|
|
30
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
31
|
+
MAX_FILES = 100_000
|
|
32
|
+
MAX_PATH = 4096
|
|
33
|
+
MAX_CANDIDATES = 20
|
|
34
|
+
HEX = /\A[0-9a-f]{64}\z/
|
|
35
|
+
MAX_SEED = (2**32) - 1
|
|
36
|
+
KEYS = %w[files format map_digest map_key root runtime seed settings].freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def path(cache_dir, map_key)
|
|
41
|
+
File.join(cache_dir, "#{PREFIX}#{map_key}.json")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def map_path(cache_dir, map_key)
|
|
45
|
+
File.join(cache_dir, "coverage-#{map_key}.json")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The snapshot of the inputs now; map_digest is filled in by #write. The
|
|
49
|
+
# seed is the one the map's spec run used: a partial rerun must reuse it.
|
|
50
|
+
def build(root:, files:, settings:, map_key:, seed:)
|
|
51
|
+
{ "format" => FORMAT, "map_key" => map_key, "map_digest" => nil, "root" => digest_of(root),
|
|
52
|
+
"runtime" => runtime_digest, "seed" => seed, "settings" => digest_of(settings.to_s),
|
|
53
|
+
"files" => files.to_h { |file| [relative(root, file), file_digest(file)] } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def runtime_digest
|
|
57
|
+
digest_of(JSON.generate(RunFingerprint.runtime_identity.merge(map_format: Selection::CoverageMap::FORMAT)))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Best effort: a failed write leaves no snapshot and never stops a run.
|
|
61
|
+
# The temp file is removed however the write ends, an interrupt included.
|
|
62
|
+
def write(cache_dir, data)
|
|
63
|
+
target = path(cache_dir, data["map_key"])
|
|
64
|
+
map_digest = file_digest(map_path(cache_dir, data["map_key"]))
|
|
65
|
+
return unless map_digest
|
|
66
|
+
|
|
67
|
+
temp = "#{target}.#{SecureRandom.hex(8)}.tmp"
|
|
68
|
+
begin
|
|
69
|
+
File.open(temp, "wx", 0o600) { |out| out.write(JSON.generate(data.merge("map_digest" => map_digest))) }
|
|
70
|
+
File.rename(temp, target)
|
|
71
|
+
ensure
|
|
72
|
+
FileUtils.rm_f(temp)
|
|
73
|
+
end
|
|
74
|
+
rescue SystemCallError, EncodingError, JSON::JSONError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The newest snapshot that passes every check, or nil.
|
|
79
|
+
def latest(cache_dir, root)
|
|
80
|
+
candidates = Dir.glob(File.join(cache_dir, "#{PREFIX}*.json")).sort_by { |file| -mtime(file) }
|
|
81
|
+
candidates.first(MAX_CANDIDATES).each do |file|
|
|
82
|
+
data = read(file, cache_dir, root)
|
|
83
|
+
return data if data
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Only a regular file is read, never a symlink, FIFO or device (their
|
|
89
|
+
# File.size is 0 and a read would not end), and never more than the cap.
|
|
90
|
+
def read(file, cache_dir, root)
|
|
91
|
+
return nil unless File.lstat(file).file?
|
|
92
|
+
|
|
93
|
+
text = File.open(file, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) { |io| io.read(MAX_BYTES + 1) }
|
|
94
|
+
return nil if text.nil? || text.bytesize > MAX_BYTES
|
|
95
|
+
|
|
96
|
+
data = JSON.parse(Text.utf8_strict(text))
|
|
97
|
+
data if valid?(data, file, cache_dir, root)
|
|
98
|
+
rescue SystemCallError, JSON::ParserError, EncodingError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def valid?(data, file, cache_dir, root)
|
|
103
|
+
return false unless data.is_a?(Hash) && data.keys.sort == KEYS && data["format"] == FORMAT
|
|
104
|
+
return false unless %w[runtime settings map_digest].all? { |key| hex?(data[key]) }
|
|
105
|
+
return false unless data["seed"].is_a?(Integer) && data["seed"].between?(0, MAX_SEED)
|
|
106
|
+
return false unless data["root"] == digest_of(root)
|
|
107
|
+
# The key becomes a path only after this: a name with a separator can never equal a basename.
|
|
108
|
+
return false unless File.basename(file) == "#{PREFIX}#{data['map_key']}.json"
|
|
109
|
+
|
|
110
|
+
files_valid?(data["files"]) && file_digest(map_path(cache_dir, data["map_key"])) == data["map_digest"]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def files_valid?(files)
|
|
114
|
+
files.is_a?(Hash) && files.size <= MAX_FILES && files.all? do |name, digest|
|
|
115
|
+
name.valid_encoding? && name.bytesize <= MAX_PATH && (digest == false || hex?(digest))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def hex?(value)
|
|
120
|
+
value.is_a?(String) && value.match?(HEX)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def mtime(file)
|
|
124
|
+
File.mtime(file).to_f
|
|
125
|
+
rescue SystemCallError
|
|
126
|
+
0.0
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def relative(root, file)
|
|
130
|
+
file.delete_prefix("#{root}/")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Only a regular file is read: a symlink, FIFO or device is false, the
|
|
134
|
+
# same as a missing file, so it never reads outside the tree or blocks.
|
|
135
|
+
def file_digest(file)
|
|
136
|
+
File.lstat(file).file? && Digest::SHA256.file(file).hexdigest
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def digest_of(text)
|
|
142
|
+
Digest::SHA256.hexdigest(text)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
class Engine
|
|
10
|
+
# Says why the coverage cache missed (#46). The map is keyed on the
|
|
11
|
+
# content of every source, spec and support file, so a pull request that
|
|
12
|
+
# edits one never hits a map built on the base branch; without a note the
|
|
13
|
+
# run just sits through a whole-suite pass. The note names how many input
|
|
14
|
+
# files changed since the last map, and points at the spec scoping flags.
|
|
15
|
+
#
|
|
16
|
+
# The comparison needs the earlier map's inputs: see
|
|
17
|
+
# CoverageInputsSnapshot, which validates what is read back from the
|
|
18
|
+
# cache (diagnostic only, never the authority for reusing a map). Every
|
|
19
|
+
# path shown goes through the caller's line cleaner, since a file name, or
|
|
20
|
+
# a key in a restored snapshot, is untrusted text that a CI log would
|
|
21
|
+
# otherwise read as a workflow command.
|
|
22
|
+
class CoverageMissNote
|
|
23
|
+
NAMED = 3
|
|
24
|
+
HINT = "; --test narrows the specs the build runs"
|
|
25
|
+
|
|
26
|
+
# settings: whatever else is part of the cache key (config inputs, version).
|
|
27
|
+
# map_key: the key the map about to be built is saved under.
|
|
28
|
+
# seed: the seed that build runs the specs with.
|
|
29
|
+
# clean: turns one path into a line-safe string.
|
|
30
|
+
def initialize(cache_dir:, root:, files:, settings:, map_key:, seed:, clean:)
|
|
31
|
+
@cache_dir = cache_dir
|
|
32
|
+
@root = root
|
|
33
|
+
@clean = clean
|
|
34
|
+
@current = CoverageInputsSnapshot.build(root: root, files: files, settings: settings, map_key: map_key,
|
|
35
|
+
seed: seed)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The inputs snapshot of the run about to build its map.
|
|
39
|
+
attr_reader :current
|
|
40
|
+
|
|
41
|
+
def message(scoped:)
|
|
42
|
+
head = "coverage cache miss: #{reason}; the map build runs "
|
|
43
|
+
scoped ? "#{head}the selected specs once" : "#{head}the whole suite once#{HINT}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Call after the map is saved: the snapshot is written last and names it.
|
|
47
|
+
def save
|
|
48
|
+
CoverageInputsSnapshot.write(@cache_dir, @current)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def reason
|
|
54
|
+
earlier = CoverageInputsSnapshot.latest(@cache_dir, @root)
|
|
55
|
+
return "no earlier map to compare with" unless earlier
|
|
56
|
+
|
|
57
|
+
now = @current["files"]
|
|
58
|
+
before = earlier["files"]
|
|
59
|
+
parts = []
|
|
60
|
+
parts << files_note(now, before) unless now == before
|
|
61
|
+
parts << "settings, Ruby or saboteur version changed" if settings_changed?(earlier)
|
|
62
|
+
parts.empty? ? "inputs match the last snapshot but its map file is missing or unusable" : parts.join("; ")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# "2 edited, 1 added, 1 removed", then the first few paths. Edited:
|
|
66
|
+
# present on both sides with another digest.
|
|
67
|
+
def files_note(now, before)
|
|
68
|
+
edited = (now.keys & before.keys).reject { |name| now[name] == before[name] }
|
|
69
|
+
added = now.keys - before.keys
|
|
70
|
+
removed = before.keys - now.keys
|
|
71
|
+
counts = { "edited" => edited, "added" => added, "removed" => removed }
|
|
72
|
+
.reject { |_, names| names.empty? }.map { |word, names| "#{names.size} #{word}" }
|
|
73
|
+
"input files changed since the last map: #{counts.join(', ')} (#{named(edited + added + removed)})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def named(names)
|
|
77
|
+
shown = names.sort.first(NAMED).map { |name| @clean.call(name) }.join(", ")
|
|
78
|
+
names.size > NAMED ? "#{shown}, +#{names.size - NAMED} more" : shown
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def settings_changed?(earlier)
|
|
82
|
+
earlier["settings"] != @current["settings"] || earlier["runtime"] != @current["runtime"]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# CoverageStore's opt-in partial rebuild (#46): after a cache miss, start
|
|
7
|
+
# from the last map and rerun only the spec files PartialInvalidation
|
|
8
|
+
# calls stale. Anything short of a clean answer (no earlier map, a change
|
|
9
|
+
# that needs a full rebuild, a failed rerun, an empty result) returns nil
|
|
10
|
+
# and the caller builds the full map, so this can only save time.
|
|
11
|
+
# Uses the store's @config, @root, @integration and @diagnostics and the
|
|
12
|
+
# shadow helpers it shares (shadow_invalidation, shadow_rerun,
|
|
13
|
+
# shadow_clean).
|
|
14
|
+
module CoveragePartial
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Same preconditions as the shadow run: a scoped or parted map is not
|
|
18
|
+
# what a partial rebuild starts from.
|
|
19
|
+
def partial_use?
|
|
20
|
+
@config["partial_coverage"] == "use" && @config["cache"] && @config["tests"].empty? && @parts == 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def partial_map(files, note, preloaded)
|
|
24
|
+
cache = @config.path("cache_dir")
|
|
25
|
+
base = CoverageInputsSnapshot.latest(cache, @root)
|
|
26
|
+
base_map = base && Selection::CoverageMap.load(CoverageInputsSnapshot.map_path(cache, base["map_key"]))
|
|
27
|
+
return nil unless base_map
|
|
28
|
+
|
|
29
|
+
stale = shadow_invalidation(base_map, files).stale_specs(base, note.current)
|
|
30
|
+
return nil if stale == PartialInvalidation::FULL
|
|
31
|
+
|
|
32
|
+
merged = stale.empty? ? base_map : PartialMerge.call(base_map, shadow_rerun(files, stale, base["seed"]), stale)
|
|
33
|
+
merged = merged.with_preloaded(preloaded)
|
|
34
|
+
return nil if merged.tests.empty?
|
|
35
|
+
|
|
36
|
+
refuse_empty_map(merged, files, preloaded)
|
|
37
|
+
@diagnostics.inform("reusing the earlier coverage map, rerunning #{stale.size} of " \
|
|
38
|
+
"#{@integration.test_files.size} spec files")
|
|
39
|
+
merged
|
|
40
|
+
rescue BudgetPhaseTimeout
|
|
41
|
+
raise
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
@diagnostics.warn("partial coverage failed, so the full map is built\n #{shadow_clean(e.message)}")
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class Engine
|
|
8
|
+
# CoverageStore's saved parts (#427): the map of each finished part of a
|
|
9
|
+
# parallel build is kept beside the whole-map cache, so a rerun after a
|
|
10
|
+
# failed or budget-stopped build only rebuilds the parts that did not
|
|
11
|
+
# finish. Uses the store's @config and cache_key.
|
|
12
|
+
module CoverageParts
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# One finished part's map, beside the cache of the whole map and under
|
|
16
|
+
# the same key, so it is valid for exactly the inputs that cache is.
|
|
17
|
+
def part_path(files, index, count)
|
|
18
|
+
File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}-part-#{index + 1}-of-#{count}.json")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Best effort, like the verdict checkpoint: a full disk or a read-only
|
|
22
|
+
# cache dir must not hide the failure this build is about to report.
|
|
23
|
+
# The saved maps of earlier runs' finished parts that still hold exactly
|
|
24
|
+
# their part's examples; nil for a part that has to be built.
|
|
25
|
+
def saved_parts(files, count, counts)
|
|
26
|
+
Array.new(count) do |index|
|
|
27
|
+
next unless @config["cache"]
|
|
28
|
+
|
|
29
|
+
map = Selection::CoverageMap.load(part_path(files, index, count))
|
|
30
|
+
map if map && map.tests.size == counts[index]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The saved parts are spent once the whole map exists.
|
|
35
|
+
def forget_parts(files, count)
|
|
36
|
+
count.times do |index|
|
|
37
|
+
FileUtils.rm_f(part_path(files, index, count))
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def save_part(map, files, index, count)
|
|
44
|
+
map.save(part_path(files, index, count)) if @config["cache"] && !map.tests.empty?
|
|
45
|
+
rescue SystemCallError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class Engine
|
|
8
|
+
# CoverageStore's opt-in shadow step (#46): after a cache miss, rebuild
|
|
9
|
+
# the map partially from the last one and compare it with the full map
|
|
10
|
+
# the run just built. Uses the store's @config, @root, @integration,
|
|
11
|
+
# @supervisor and @diagnostics, and its record and map_from.
|
|
12
|
+
module CoverageShadow
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# The opt-in shadow run (#46) needs the single whole-suite build: a
|
|
16
|
+
# scoped or parted map is not what a partial rebuild is compared with.
|
|
17
|
+
def shadow?
|
|
18
|
+
@config["partial_coverage"] == "shadow" && @config["cache"] && @config["tests"].empty? && @parts == 1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Never stops a run: the full map is already built and is the one used.
|
|
22
|
+
# Writes shadow-<map key>.json beside the map, with the seconds the full
|
|
23
|
+
# build and the partial rerun took, and warns on a mismatch.
|
|
24
|
+
def shadow(files, full_map, note, build_seconds)
|
|
25
|
+
cache = @config.path("cache_dir")
|
|
26
|
+
base = CoverageInputsSnapshot.latest(cache, @root)
|
|
27
|
+
base_map = base && Selection::CoverageMap.load(CoverageInputsSnapshot.map_path(cache, base["map_key"]))
|
|
28
|
+
return unless base_map
|
|
29
|
+
|
|
30
|
+
rerun_seconds = nil
|
|
31
|
+
report = ShadowRun.call(base: base, base_map: base_map, current: note.current, full_map: full_map,
|
|
32
|
+
invalidation: shadow_invalidation(base_map, files), fallback_files: files,
|
|
33
|
+
rerun: lambda { |specs, seed|
|
|
34
|
+
from = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
shadow_rerun(files, specs, seed).tap do
|
|
36
|
+
rerun_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - from
|
|
37
|
+
end
|
|
38
|
+
}, clean: method(:shadow_clean))
|
|
39
|
+
report = report.merge("full_build_seconds" => build_seconds.round(2),
|
|
40
|
+
"rerun_seconds" => rerun_seconds&.round(2))
|
|
41
|
+
save_shadow_report(cache, report, cache_key(files))
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
@diagnostics.warn("partial coverage shadow run failed, so the full map is used\n #{shadow_clean(e.message)}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def shadow_invalidation(base_map, files)
|
|
47
|
+
PartialInvalidation.new(map: base_map, root: @root, included: files.map(&method(:relative)),
|
|
48
|
+
spec_files: @integration.test_files.map(&method(:relative)),
|
|
49
|
+
status_file: @integration.status_file?(@root))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def relative(file)
|
|
53
|
+
file.delete_prefix("#{@root}/")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The stale spec files, run once in a child with the seed the base map
|
|
57
|
+
# was built with, over exactly the runnable spec files named. It writes
|
|
58
|
+
# the store's usual map build log, which the full build is done with.
|
|
59
|
+
def shadow_rerun(files, specs, seed)
|
|
60
|
+
Execution::ForkReadiness.prepare_heap
|
|
61
|
+
admitted = build_timeout
|
|
62
|
+
result = @supervisor.run(timeout: admitted, label: "coverage child died during the shadow rerun") do |writer|
|
|
63
|
+
record(writer, files, only: specs.map { |file| File.join(@root, file) }, seed: seed)
|
|
64
|
+
end
|
|
65
|
+
map_from(result, admitted)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def save_shadow_report(cache, report, key)
|
|
69
|
+
target = File.join(cache, "shadow-#{key}.json")
|
|
70
|
+
temp = "#{target}.#{SecureRandom.hex(8)}.tmp"
|
|
71
|
+
begin
|
|
72
|
+
File.open(temp, "wx", 0o600) { |out| out.write(JSON.pretty_generate(report)) }
|
|
73
|
+
File.rename(temp, target)
|
|
74
|
+
ensure
|
|
75
|
+
FileUtils.rm_f(temp)
|
|
76
|
+
end
|
|
77
|
+
warn_mismatch(report, target) if report["result"] == "mismatch"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def warn_mismatch(report, target)
|
|
81
|
+
first = report["differences"].first
|
|
82
|
+
@diagnostics.warn("partial coverage shadow run: this run used the full map\n " \
|
|
83
|
+
"the partial map differs from the full map in #{report['total']} place(s), " \
|
|
84
|
+
"first #{first['kind']} #{first['name']}\n Report: #{shadow_clean(target)}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def shadow_clean(value)
|
|
88
|
+
plain_line(Text.utf8(value.to_s))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|