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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +44 -6
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/attribute_finder.rb +116 -0
  6. data/lib/saboteur/baseline/run_evidence.rb +2 -1
  7. data/lib/saboteur/cli/agent_provenance.rb +59 -0
  8. data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
  9. data/lib/saboteur/cli/doctor_command.rb +41 -0
  10. data/lib/saboteur/cli/error_cause.rb +40 -0
  11. data/lib/saboteur/cli/execution_options.rb +46 -0
  12. data/lib/saboteur/cli/exit_status.rb +12 -14
  13. data/lib/saboteur/cli/explicit_file_options.rb +21 -0
  14. data/lib/saboteur/cli/explicit_sources.rb +46 -0
  15. data/lib/saboteur/cli/failure_output.rb +35 -0
  16. data/lib/saboteur/cli/help_command.rb +23 -0
  17. data/lib/saboteur/cli/matrix_option.rb +17 -0
  18. data/lib/saboteur/cli/output_options.rb +27 -0
  19. data/lib/saboteur/cli/presets.rb +41 -1
  20. data/lib/saboteur/cli/preview_command.rb +26 -0
  21. data/lib/saboteur/cli/run_budget_options.rb +6 -1
  22. data/lib/saboteur/cli/run_command.rb +33 -0
  23. data/lib/saboteur/cli/run_output.rb +38 -0
  24. data/lib/saboteur/cli/run_reporting.rb +6 -5
  25. data/lib/saboteur/cli/run_validation.rb +54 -0
  26. data/lib/saboteur/cli/source_set.rb +0 -18
  27. data/lib/saboteur/cli.rb +29 -305
  28. data/lib/saboteur/config.rb +76 -7
  29. data/lib/saboteur/config_file.rb +12 -8
  30. data/lib/saboteur/constant_subject_finder.rb +3 -0
  31. data/lib/saboteur/custom_map.rb +24 -5
  32. data/lib/saboteur/deep_source.rb +49 -0
  33. data/lib/saboteur/dirty_tree_warning.rb +6 -4
  34. data/lib/saboteur/doctor.rb +10 -0
  35. data/lib/saboteur/engine/baseline_guard.rb +118 -15
  36. data/lib/saboteur/engine/baseline_parts.rb +51 -0
  37. data/lib/saboteur/engine/boot.rb +11 -3
  38. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  39. data/lib/saboteur/engine/candidate_selection.rb +16 -5
  40. data/lib/saboteur/engine/candidates.rb +4 -1
  41. data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
  42. data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
  43. data/lib/saboteur/engine/coverage_partial.rb +48 -0
  44. data/lib/saboteur/engine/coverage_parts.rb +50 -0
  45. data/lib/saboteur/engine/coverage_shadow.rb +92 -0
  46. data/lib/saboteur/engine/coverage_store.rb +296 -21
  47. data/lib/saboteur/engine/diagnostics.rb +24 -7
  48. data/lib/saboteur/engine/distribution.rb +2 -2
  49. data/lib/saboteur/engine/gated_run.rb +4 -1
  50. data/lib/saboteur/engine/load_coverage.rb +72 -8
  51. data/lib/saboteur/engine/partial_invalidation.rb +115 -0
  52. data/lib/saboteur/engine/partial_merge.rb +40 -0
  53. data/lib/saboteur/engine/pipelines.rb +11 -3
  54. data/lib/saboteur/engine/reference_specs.rb +71 -0
  55. data/lib/saboteur/engine/result_cache.rb +68 -5
  56. data/lib/saboteur/engine/resume_manifest.rb +110 -0
  57. data/lib/saboteur/engine/run_budget.rb +10 -7
  58. data/lib/saboteur/engine/run_fingerprint.rb +2 -1
  59. data/lib/saboteur/engine/sandbox_policy.rb +26 -5
  60. data/lib/saboteur/engine/scheduler.rb +33 -25
  61. data/lib/saboteur/engine/selection_announcement.rb +9 -3
  62. data/lib/saboteur/engine/shadow_comparison.rb +121 -0
  63. data/lib/saboteur/engine/shadow_run.rb +38 -0
  64. data/lib/saboteur/engine/subject_selection.rb +144 -64
  65. data/lib/saboteur/engine/suite_partition.rb +22 -0
  66. data/lib/saboteur/engine/test_guess.rb +186 -32
  67. data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
  68. data/lib/saboteur/engine.rb +44 -11
  69. data/lib/saboteur/environment_hints.rb +55 -0
  70. data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
  71. data/lib/saboteur/execution/external_runner.rb +123 -1
  72. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  73. data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
  74. data/lib/saboteur/execution/marker_sweep.rb +2 -1
  75. data/lib/saboteur/execution/process_table.rb +7 -3
  76. data/lib/saboteur/execution/reevaluator.rb +7 -0
  77. data/lib/saboteur/execution/sandbox.rb +39 -20
  78. data/lib/saboteur/execution/seatbelt.rb +10 -3
  79. data/lib/saboteur/execution/supervisor.rb +42 -8
  80. data/lib/saboteur/execution/supervisor_batch.rb +105 -0
  81. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  82. data/lib/saboteur/execution/worker_pool.rb +48 -4
  83. data/lib/saboteur/frameworks/rails.rb +2 -5
  84. data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
  85. data/lib/saboteur/gates.rb +121 -28
  86. data/lib/saboteur/install.rb +6 -1
  87. data/lib/saboteur/integration/base.rb +19 -2
  88. data/lib/saboteur/integration/rspec.rb +145 -18
  89. data/lib/saboteur/line_hits.rb +81 -0
  90. data/lib/saboteur/method_calls.rb +91 -0
  91. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  92. data/lib/saboteur/mutation_source/builtin.rb +33 -21
  93. data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
  94. data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
  95. data/lib/saboteur/mutation_source/jump_value.rb +59 -0
  96. data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
  97. data/lib/saboteur/mutation_source/operator_examples.rb +6 -0
  98. data/lib/saboteur/mutation_source/operator_registry.rb +6 -0
  99. data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
  100. data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
  101. data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
  102. data/lib/saboteur/mutation_source/when_removal.rb +51 -0
  103. data/lib/saboteur/preview.rb +13 -0
  104. data/lib/saboteur/refinement_activations.rb +22 -10
  105. data/lib/saboteur/reporter/agent.rb +2 -2
  106. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  107. data/lib/saboteur/reporter/agent_summary.rb +16 -10
  108. data/lib/saboteur/reporter/review_comments.rb +151 -0
  109. data/lib/saboteur/reporter/safe_out.rb +32 -0
  110. data/lib/saboteur/reporter/stryker.rb +8 -6
  111. data/lib/saboteur/reporter/survivor_classification.rb +41 -3
  112. data/lib/saboteur/reporter/survivor_hints.rb +5 -0
  113. data/lib/saboteur/reporter/terminal.rb +45 -5
  114. data/lib/saboteur/result.rb +14 -4
  115. data/lib/saboteur/selection/coverage_map.rb +150 -9
  116. data/lib/saboteur/selection/external_test_map.rb +4 -0
  117. data/lib/saboteur/selection/git_diff.rb +24 -2
  118. data/lib/saboteur/selection/layout_only_lines.rb +45 -1
  119. data/lib/saboteur/selection/recorder.rb +52 -22
  120. data/lib/saboteur/selection/trace_recorder.rb +83 -0
  121. data/lib/saboteur/selection/untracked_changes.rb +1 -2
  122. data/lib/saboteur/subject.rb +16 -0
  123. data/lib/saboteur/subject_finder.rb +91 -6
  124. data/lib/saboteur/text.rb +31 -0
  125. data/lib/saboteur/version.rb +1 -1
  126. data/lib/saboteur/working_tree.rb +7 -2
  127. data/lib/saboteur.rb +9 -0
  128. 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