saboteur 0.7.0 → 0.8.1

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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +772 -0
  3. data/README.md +221 -1072
  4. data/lib/saboteur/attribute_finder.rb +113 -0
  5. data/lib/saboteur/cli/exit_status.rb +32 -1
  6. data/lib/saboteur/cli/help_options.rb +2 -1
  7. data/lib/saboteur/cli/history_command.rb +135 -0
  8. data/lib/saboteur/cli/presets.rb +47 -3
  9. data/lib/saboteur/cli/run_budget_options.rb +3 -1
  10. data/lib/saboteur/cli/selection_options.rb +31 -0
  11. data/lib/saboteur/cli/shard_options.rb +17 -0
  12. data/lib/saboteur/cli/source_set.rb +35 -0
  13. data/lib/saboteur/cli/survivors_command.rb +157 -0
  14. data/lib/saboteur/cli.rb +74 -55
  15. data/lib/saboteur/config.rb +102 -6
  16. data/lib/saboteur/config_file.rb +19 -7
  17. data/lib/saboteur/constant_reads.rb +182 -0
  18. data/lib/saboteur/constant_subject.rb +70 -0
  19. data/lib/saboteur/constant_subject_finder.rb +281 -0
  20. data/lib/saboteur/coverage_reads.rb +29 -0
  21. data/lib/saboteur/custom_map.rb +12 -1
  22. data/lib/saboteur/dirty_tree_warning.rb +130 -0
  23. data/lib/saboteur/doctor.rb +13 -0
  24. data/lib/saboteur/engine/baseline_guard.rb +135 -21
  25. data/lib/saboteur/engine/boot.rb +11 -3
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  27. data/lib/saboteur/engine/candidate_selection.rb +20 -0
  28. data/lib/saboteur/engine/candidates.rb +43 -3
  29. data/lib/saboteur/engine/coverage_store.rb +58 -14
  30. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  31. data/lib/saboteur/engine/diagnostics.rb +19 -1
  32. data/lib/saboteur/engine/distribution.rb +10 -0
  33. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  34. data/lib/saboteur/engine/gated_run.rb +37 -0
  35. data/lib/saboteur/engine/load_coverage.rb +63 -0
  36. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  37. data/lib/saboteur/engine/pipelines.rb +115 -0
  38. data/lib/saboteur/engine/result_cache.rb +65 -8
  39. data/lib/saboteur/engine/run_budget.rb +32 -2
  40. data/lib/saboteur/engine/sandbox_policy.rb +59 -3
  41. data/lib/saboteur/engine/scheduler.rb +131 -17
  42. data/lib/saboteur/engine/selection_announcement.rb +59 -0
  43. data/lib/saboteur/engine/shard.rb +69 -0
  44. data/lib/saboteur/engine/subject_selection.rb +380 -21
  45. data/lib/saboteur/engine/test_guess.rb +407 -27
  46. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  47. data/lib/saboteur/engine.rb +103 -129
  48. data/lib/saboteur/execution/external_runner.rb +489 -0
  49. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  50. data/lib/saboteur/execution/marker_sweep.rb +80 -0
  51. data/lib/saboteur/execution/peak_memory.rb +28 -0
  52. data/lib/saboteur/execution/process_table.rb +19 -3
  53. data/lib/saboteur/execution/process_tree.rb +65 -5
  54. data/lib/saboteur/execution/reevaluator.rb +75 -12
  55. data/lib/saboteur/execution/runner.rb +46 -7
  56. data/lib/saboteur/execution/sandbox.rb +25 -6
  57. data/lib/saboteur/execution/seatbelt.rb +233 -0
  58. data/lib/saboteur/execution/supervisor.rb +29 -11
  59. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  60. data/lib/saboteur/execution/worker_pool.rb +23 -1
  61. data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -0
  62. data/lib/saboteur/gates.rb +43 -2
  63. data/lib/saboteur/help.rb +60 -9
  64. data/lib/saboteur/ignore_list.rb +29 -6
  65. data/lib/saboteur/integration/rspec.rb +223 -5
  66. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  67. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  68. data/lib/saboteur/mutation_source/builtin.rb +28 -5
  69. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  70. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  71. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  72. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  73. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  74. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  75. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  76. data/lib/saboteur/mutation_source/hazard_guard.rb +189 -0
  77. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  79. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  80. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  81. data/lib/saboteur/mutation_source/operator_registry.rb +5 -2
  82. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  83. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  84. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  85. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  86. data/lib/saboteur/preview.rb +125 -12
  87. data/lib/saboteur/refinement_activations.rb +60 -0
  88. data/lib/saboteur/reporter/agent.rb +9 -1
  89. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  90. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  91. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  92. data/lib/saboteur/reporter/findings.rb +5 -2
  93. data/lib/saboteur/reporter/history_policy.rb +11 -0
  94. data/lib/saboteur/reporter/history_reader.rb +47 -0
  95. data/lib/saboteur/reporter/html.rb +26 -4
  96. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  97. data/lib/saboteur/reporter/stryker.rb +27 -3
  98. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  99. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  100. data/lib/saboteur/reporter/terminal.rb +56 -8
  101. data/lib/saboteur/result.rb +19 -3
  102. data/lib/saboteur/selection/coverage_map.rb +120 -10
  103. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  104. data/lib/saboteur/selection/external_test_map.rb +27 -0
  105. data/lib/saboteur/selection/git_diff.rb +93 -11
  106. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  107. data/lib/saboteur/selection/recorder.rb +22 -4
  108. data/lib/saboteur/selection/source_scope.rb +22 -0
  109. data/lib/saboteur/subject.rb +42 -7
  110. data/lib/saboteur/subject_finder.rb +173 -29
  111. data/lib/saboteur/version.rb +1 -1
  112. data/lib/saboteur/working_tree.rb +38 -15
  113. data/lib/saboteur.rb +14 -0
  114. metadata +39 -16
@@ -15,7 +15,8 @@ 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:, load_lines: {}, timeout_cap: nil)
18
+ def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, load_lines: {}, load_methods: {},
19
+ timeout_cap: nil)
19
20
  @config = config
20
21
  @integration = integration
21
22
  @hooks = hooks
@@ -23,7 +24,9 @@ module Saboteur
23
24
  @diagnostics = diagnostics
24
25
  @timeout_cap = timeout_cap
25
26
  @load_lines = load_lines
27
+ @load_methods = load_methods
26
28
  @root = File.realpath(config.root)
29
+ @root_prefixes = Selection::CoverageMap.root_prefixes(config.root)
27
30
  end
28
31
 
29
32
  # True when fetch would reuse the cached map instead of running the
@@ -37,10 +40,11 @@ module Saboteur
37
40
 
38
41
  def fetch(files)
39
42
  path = cache_path(files)
43
+ preloaded = LoadCoverage.preloaded_uninstrumented(files)
40
44
  cached = cached_map(files)
41
- return refuse_empty_map(cached, files) if cached
45
+ return refuse_empty_map(cached.with_preloaded(preloaded), files, preloaded) if cached
42
46
 
43
- map = refuse_empty_map(build(files), files)
47
+ map = refuse_empty_map(build(files).with_preloaded(preloaded), files, preloaded)
44
48
  map.save(path)
45
49
  File.write(File.join(@config.path("cache_dir"), "preview.json"),
46
50
  JSON.generate(path: path, fingerprint: RunFingerprint.new(config: @config).snapshot.digest))
@@ -49,6 +53,21 @@ module Saboteur
49
53
 
50
54
  private
51
55
 
56
+ def write_map(writer, recorder, build_seconds)
57
+ writer.write(recorder.result(build_seconds: build_seconds, loaded_files: loaded_project_files).to_json)
58
+ writer.close
59
+ end
60
+
61
+ # Every project source this child loaded, even a lazy require outside the
62
+ # include list that the recorder drops: --incremental must watch it, or a
63
+ # change there keeps stale verdicts (#353).
64
+ def loaded_project_files
65
+ $LOADED_FEATURES.filter_map do |file|
66
+ path = Text.utf8_strict(Selection::CoverageMap.canonical(file))
67
+ path if path.end_with?(".rb") && @root_prefixes.any? { |prefix| path.start_with?(prefix) }
68
+ end
69
+ end
70
+
52
71
  def cache_path(files)
53
72
  File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
54
73
  end
@@ -122,13 +141,16 @@ module Saboteur
122
141
  # The parent's load lines merge into the freshly built map: the forked
123
142
  # build child cannot inherit the parent's Coverage counters.
124
143
  def with_parent_load_lines(map)
125
- @load_lines.empty? ? map : map.with_load_lines(@load_lines)
144
+ @load_lines.empty? && @load_methods.empty? ? map : map.with_load_lines(@load_lines, @load_methods)
126
145
  end
127
146
 
128
147
  # A whole-suite build that records nothing - no line hit and no method
129
- # call - in any included file fails the run (exit 3): usually no test
130
- # in the suite exercises these files, or the setup hid the app from
131
- # Coverage (Bootsnap's compile cache is the default Rails case). An
148
+ # call - in any included file fails the run (exit 3). The recorder's map
149
+ # also holds the suite's spec files, so the check is per included file,
150
+ # not whole-map: a suite that tests nothing here still records its own
151
+ # spec code. Usually no test in the suite exercises these files, or
152
+ # the setup hid the app from Coverage (Bootsnap's compile cache is the
153
+ # default Rails case). An
132
154
  # all-endless-def file records method calls only and passes. Either way
133
155
  # a run on a truly empty map would test nothing, and exit 0 would lie.
134
156
  # With explicit tests (--test) an empty map is a legitimate outcome
@@ -136,9 +158,20 @@ module Saboteur
136
158
  # these sources, so the map passes with a warning. Applies to a cached
137
159
  # map too, so a map cached before this check cannot keep a run green,
138
160
  # and the guard runs before saving, so an empty map is never cached.
139
- def refuse_empty_map(map, files)
140
- return map if files.empty? || !map.empty?
141
-
161
+ def refuse_empty_map(map, files, preloaded)
162
+ return map if files.empty? || !map.empty_for?(files)
163
+
164
+ # A file loaded before the executable's Coverage.start can never be
165
+ # instrumented, so emptiness there is a setup defect, not untested
166
+ # code: name it apart from the generic no-coverage refusal.
167
+ preloaded_empty = preloaded & files.map { |file| Selection::CoverageMap.canonical(file) }
168
+ if preloaded_empty.any?
169
+ message = preloaded_message(preloaded_empty)
170
+ raise Error, message if @config["tests"].empty?
171
+
172
+ @diagnostics.warn(message)
173
+ return map
174
+ end
142
175
  if @config["tests"].empty?
143
176
  raise Error, "Coverage recorded nothing in #{files.size} included files during a whole-suite " \
144
177
  "build. No test in the suite may exercise these files, or the setup hid the app " \
@@ -149,6 +182,16 @@ module Saboteur
149
182
  map
150
183
  end
151
184
 
185
+ def preloaded_message(files)
186
+ names = files.first(5).map { |file| file.delete_prefix("#{@root}/") }
187
+ more = files.size > 5 ? ", ..." : ""
188
+ "#{names.join(', ')}#{more} loaded before saboteur started coverage (a require ran before the " \
189
+ "executable's Coverage.start - commonly the gemspec requiring the library while bundler/setup " \
190
+ "evaluates it, or RUBYOPT -r), so no test can ever cover #{files.size > 1 ? 'them' : 'it'}. " \
191
+ "Let the test suite load these " \
192
+ "files; a gemspec should require only its version file."
193
+ end
194
+
152
195
  # Bootsnap is named only when it is loaded: blaming a library the app
153
196
  # does not use sends the reader to the wrong place.
154
197
  def bootsnap_note
@@ -175,7 +218,10 @@ module Saboteur
175
218
  $stderr.reopen($stdout)
176
219
  @hooks.run(:after_fork)
177
220
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
178
- recorder = Selection::Recorder.new(files)
221
+ # The suite's own files are recorded too: a spec can read a
222
+ # constant directly, and without its line hits no test maps to the
223
+ # read (#319).
224
+ recorder = Selection::Recorder.new(files + @integration.test_files)
179
225
  recorder.start
180
226
  Kernel.srand(@config["seed"])
181
227
  passed = @integration.run_all(recorder)
@@ -183,9 +229,7 @@ module Saboteur
183
229
  raise Error, "the unchanged suite failed while building coverage. Fix the suite, then run again " \
184
230
  "with seed #{@config['seed']}."
185
231
  end
186
- build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
187
- writer.write(recorder.result(build_seconds: build_seconds).to_json)
188
- writer.close
232
+ write_map(writer, recorder, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
189
233
  rescue Exception => e # rubocop:disable Lint/RescueException
190
234
  writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
191
235
  exit!(1)
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # The examples an order-dependent spec file needs, in the order RSpec
7
+ # defines them: for each file with a covering example, every example from
8
+ # the start of the file through its last covering example. Later examples
9
+ # cannot change earlier ones, so this is the smallest run that keeps
10
+ # whatever state the earlier examples leave behind.
11
+ class DefinedOrderPrefix
12
+ ID_PATTERN = /\A(?<file>.*)\[(?<path>[\d:]+)\]\z/
13
+
14
+ # every_test: all example ids the map knows, in any order.
15
+ def initialize(every_test)
16
+ @every_test = every_test
17
+ end
18
+
19
+ # The prefix for the covering examples; nil when it adds nothing to
20
+ # what already ran (an id that is not an RSpec id included).
21
+ def for(covering)
22
+ parsed = covering.map { |id| parse(id) }
23
+ return nil if parsed.any?(&:nil?)
24
+
25
+ prefix = parsed.group_by(&:first).flat_map { |file, entries| file_prefix(file, entries.map(&:last).max) }
26
+ prefix == covering ? nil : prefix
27
+ end
28
+
29
+ private
30
+
31
+ def file_prefix(file, last_position)
32
+ in_file = @every_test.filter_map { |id| (entry = parse(id)) && entry.first == file ? entry : nil }
33
+ through = in_file.select { |entry| (entry.last <=> last_position) <= 0 }.sort_by(&:last)
34
+ through.map { |entry| "#{file}[#{entry.last.join(':')}]" }
35
+ end
36
+
37
+ def parse(id)
38
+ match = ID_PATTERN.match(id)
39
+ match && [match[:file], match[:path].split(":").map(&:to_i)]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -74,7 +74,7 @@ module Saboteur
74
74
  DiscoveryFailure = Data.define(:file, :reason)
75
75
 
76
76
  attr_reader :warnings, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
77
- :hazard_suppressions, :inline_suppressions
77
+ :hazard_suppressions, :inline_suppressions, :verbose
78
78
 
79
79
  def initialize(err, verbose: false)
80
80
  @err = err
@@ -86,12 +86,30 @@ module Saboteur
86
86
  @discovery_failures = []
87
87
  @hazard_suppressions = 0
88
88
  @inline_suppressions = []
89
+ @erb_template_skips = []
90
+ @erb_template_note_emitted = false
89
91
  end
90
92
 
91
93
  def warn(message)
92
94
  @warnings << message
93
95
  end
94
96
 
97
+ # ERB generator templates (Rails generators carry `.rb` files with ERB
98
+ # interpolation, conventionally under `lib/generators/**/templates/`)
99
+ # can never parse as plain Ruby, so they can never hold mutation
100
+ # subjects (#372). One summarized note per run replaces the per-file
101
+ # parse warning every discovery pass used to print; a real syntax
102
+ # error in a plain Ruby file still warns per file.
103
+ def record_erb_template_skip(file, root:)
104
+ relative = file.to_s.delete_prefix("#{root}/")
105
+ @erb_template_skips << relative.freeze unless @erb_template_skips.include?(relative)
106
+ return if @erb_template_note_emitted
107
+
108
+ @erb_template_note_emitted = true
109
+ warn("skipping ERB template files that cannot parse as Ruby " \
110
+ "(for example #{relative}); templates are not mutation subjects")
111
+ end
112
+
95
113
  def count_invalid_mutation
96
114
  @invalid_mutations += 1
97
115
  end
@@ -15,6 +15,16 @@ module Saboteur
15
15
  # whose fresh kills the spent budget left unchecked (#296).
16
16
  attr_reader :unreached, :truncated, :unverified
17
17
 
18
+ # The run's peak-memory record for the JSON report (#202): each
19
+ # worker's kernel peak RSS and the supervisor's own, nil when the
20
+ # platform measured neither so the report omits the section. No total:
21
+ # fork copy-on-write makes per-process RSS non-additive.
22
+ def memory_report
23
+ workers = (@pool&.worker_peaks || {}).sort.map { |number, mb| { worker: number, peakMb: mb } }
24
+ supervisor = Execution::PeakMemory.peak_mb
25
+ workers.empty? && supervisor.nil? ? nil : { workers: workers, supervisorPeakMb: supervisor }.compact
26
+ end
27
+
18
28
  # stop_after: nil, or a callable that gets one subject's results and
19
29
  # returns true to stop before the next subject (--fail-fast). It only
20
30
  # works on one process: parallel workers have already started the
@@ -10,12 +10,13 @@ module Saboteur
10
10
  private
11
11
 
12
12
  # The subjects a stop left unrun or mid-way: the verdict covers less than the selection.
13
- def record_early_stop(distribution)
13
+ def record_early_stop(distribution, planned_mutants: nil)
14
14
  return if distribution.unreached.empty? && distribution.truncated.empty? && distribution.unverified.empty?
15
15
 
16
16
  truncated = distribution.truncated.map { |subject, omitted| { subject: subject.id, omitted: omitted } }
17
17
  @early_stop = EarlyStop.new(reason: stop_reason(distribution), unreached: distribution.unreached.map(&:id),
18
- truncated: truncated, unverified: distribution.unverified.map(&:id))
18
+ truncated: truncated, unverified: distribution.unverified.map(&:id),
19
+ planned_mutants: planned_mutants)
19
20
  end
20
21
 
21
22
  # Mid-subject omissions and skipped checks are budget work: only a
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # A threshold or mutant-count gate judges the score, so a path that
7
+ # narrows what the score covers (a partially mapped --since run, #370)
8
+ # must keep its hard stop instead of green-lighting the change.
9
+ module GatedRun
10
+ private
11
+
12
+ def gated?
13
+ config["threshold"] || Gates.new(config).any?
14
+ end
15
+
16
+ # Reports the guess's skipped and subjectless files, then stops or
17
+ # warns on unmapped files per the gate (#370).
18
+ def report_unmapped(guess)
19
+ @diagnostics.inform(guess.subjectless_message(since: config["since"])) if guess.subjectless.any?
20
+ inform_skipped_specs(guess)
21
+ stop_or_warn_unmapped(guess)
22
+ end
23
+
24
+ # A --since run with a partial mapping runs the mapped subset with a
25
+ # loud warning per unmapped file (#370); a full run, a zero guess and
26
+ # a gated run keep the hard stop.
27
+ def stop_or_warn_unmapped(guess)
28
+ return if guess.unmatched.empty?
29
+ if !config["since"] || guess.specs.empty? || gated?
30
+ raise ConfigError, guess.unmatched_message(verbose: @diagnostics.verbose)
31
+ end
32
+
33
+ guess.unmatched.each { |file| @diagnostics.announce(guess.unmatched_warning(file)) }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,63 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "coverage"
5
+
6
+ module Saboteur
7
+ class Engine
8
+ # The boot's coverage snapshot, read in the parent because a forked
9
+ # child does not inherit Coverage counters: the lines and the def
10
+ # calls that ran while the app and spec files loaded. The executable
11
+ # counts from its first line, so this snapshot holds the whole boot.
12
+ # The lines tell ran-at-load mutants apart from uncovered ones; the
13
+ # def calls name def-time macros whose only caller ran here, so their
14
+ # mutants read as ran at load instead of surviving on the pristine
15
+ # closure (#409).
16
+ module LoadCoverage
17
+ # Returns [load_lines, load_methods]: file => line numbers for each.
18
+ def self.capture(files)
19
+ return [{}, {}] unless Coverage.state == :suspended
20
+
21
+ wanted = files.to_set { |file| Selection::CoverageMap.canonical(file) }
22
+ CoverageReads.result(stop: false, clear: false)
23
+ .each_with_object([{}, {}]) do |(path, data), (lines_h, methods_h)|
24
+ file = Selection::CoverageMap.canonical(path)
25
+ next unless wanted.include?(file)
26
+
27
+ lines = data[:lines].each_with_index.filter_map { |count, index| index + 1 if count&.positive? }
28
+ lines -= uncalled_shared_lines(data)
29
+ lines_h[file] = lines unless lines.empty?
30
+ defs = data.fetch(:methods, {}).filter_map { |(_owner, _name, first, *), count| first if count.positive? }
31
+ methods_h[file] = defs.uniq.sort unless defs.empty?
32
+ end
33
+ end
34
+
35
+ # Include-listed files that are already loaded yet carry no coverage
36
+ # instrumentation: something required them before the executable's
37
+ # Coverage.start (a gemspec requiring the library while bundler/setup
38
+ # evaluates it, or RUBYOPT -r), so no test can ever be attributed to
39
+ # them. Read in the parent, where the snapshot still reflects the boot.
40
+ def self.preloaded_uninstrumented(files, loaded: $LOADED_FEATURES)
41
+ return [] unless Coverage.state == :suspended
42
+
43
+ instrumented = CoverageReads.result(stop: false, clear: false)
44
+ .each_key
45
+ .to_set { |path| Selection::CoverageMap.canonical(path) }
46
+ loaded_set = loaded.to_set { |path| Selection::CoverageMap.canonical(path) }
47
+ files.map { |file| Selection::CoverageMap.canonical(file) }
48
+ .uniq
49
+ .reject { |file| instrumented.include?(file) }
50
+ .select { |file| loaded_set.include?(file) }
51
+ end
52
+
53
+ # A one-line or endless definition executes its def line even when its
54
+ # body is never called. If any sibling on that physical line is uncalled,
55
+ # discard its ambiguous line evidence rather than crediting every body.
56
+ def self.uncalled_shared_lines(data)
57
+ shared = data.fetch(:methods, {}).select { |(_owner, _name, first, _column, last, *), _| first == last }
58
+ calls = shared.group_by { |(_owner, _name, first, *), _count| first }
59
+ calls.filter_map { |line, entries| line if entries.any? { |_key, count| count.zero? } }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # The --since stop whose changed source files all sit outside the
7
+ # include scope (#483), kept apart from the guess flow it guards.
8
+ module OutOfScopeGuard
9
+ private
10
+
11
+ # Every changed source file sits outside the include scope (#483): the
12
+ # whole-suite fallback would run the slowest possible suite against
13
+ # zero subjects, so stop and name what the scope hid.
14
+ def refuse_out_of_scope_changes(guess)
15
+ return unless guess.changed.zero? && guess.out_of_scope.any?
16
+
17
+ raise ConfigError, guess.out_of_scope_message
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,115 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../execution/test_command_shape"
5
+
6
+ module Saboteur
7
+ class Engine
8
+ # The two run pipelines: in-process (boot, coverage, workers) and the
9
+ # --test-command external mode (#140).
10
+ module Pipelines
11
+ # The in-process pipeline: boot the target, mutate under coverage, and
12
+ # clean the workers up however the run ends.
13
+ def run_pipeline(ignore_list, started)
14
+ return call_external(ignore_list, started) if external_command?
15
+
16
+ boot = Boot.new(config, hooks)
17
+ begin
18
+ integration, files, load_lines, load_methods = boot_target(boot)
19
+ @rails = boot.rails
20
+ subjects, scope = select_subjects(files, test_files: integration.test_files)
21
+ results = if subjects.empty?
22
+ []
23
+ else
24
+ mutate(subjects, files, integration, scope, ignore_list,
25
+ load_lines:, load_methods:)
26
+ end
27
+ @diagnostics.warnings.concat(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
28
+ build_report(results, subjects, scope, started)
29
+ ensure
30
+ # Cleanup runs however the call ends - a partway boot failure, no
31
+ # subjects, no runnable work - so a host embedding Engine never
32
+ # holds worker copies until process exit. Idempotent; the Rails
33
+ # profile's at_exit handler stays the fallback for abrupt exit.
34
+ begin
35
+ @coverage_guard&.resume # an embedded host gets its coverage back (#254)
36
+ ensure
37
+ safely_cleanup_workers(boot.rails)
38
+ end
39
+ end
40
+ end
41
+
42
+ # The --test-command pipeline (#140): no boot, no coverage, no
43
+ # baseline - every mutant runs the command on the mutated tree,
44
+ # serially, with no sandbox. The command is arbitrary user code.
45
+ def call_external(ignore_list, started)
46
+ @diagnostics.inform("external test command: #{config['test_command']} " \
47
+ "(serial, no coverage, unsandboxed, mutants applied in a disposable copy)")
48
+ if Execution::TestCommandShape.multi_file_ruby?(config["test_command"])
49
+ @diagnostics.warn("the test command hands several test files to one ruby: ruby executes only " \
50
+ "the first, so the rest never run and their mutants silently survive (#611) - " \
51
+ "use the framework's multi-file form (bin/rails test f1 f2, rspec f1 f2) or a " \
52
+ "shell loop (for f in ...; do ruby \"$f\" || exit 1; done)")
53
+ end
54
+ files = config.included_files
55
+ subjects, scope = select_subjects(files)
56
+ results = subjects.empty? ? [] : mutate_external(subjects, scope, ignore_list)
57
+ @diagnostics.warnings.concat(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
58
+ build_report(results, subjects, scope, started)
59
+ end
60
+
61
+ def mutate_external(subjects, scope, ignore_list)
62
+ runner = external_runner
63
+ # One neutral run before any mutant exists: a command that fails on
64
+ # the unchanged tree can never claim kills (#305). It answers to the
65
+ # run budget with an ABSOLUTE deadline, admitted after the tree copy:
66
+ # a budget already spent is a budget stop, not a command error.
67
+ begin
68
+ runner.preflight!(deadline: preflight_deadline, budget_deadline: budget_deadline)
69
+ rescue BudgetPhaseTimeout => e
70
+ return budget_phase_stop(e, subjects)
71
+ end
72
+ map = Selection::ExternalTestMap.new
73
+ scheduler = Scheduler.new(config:, runner:, map:,
74
+ reevaluator: Execution::Reevaluator.new(config["reeval"],
75
+ whole_file: config["whole_file"]),
76
+ diagnostics: @diagnostics, deadline: budget_deadline, verify_loaded: false)
77
+ lists = candidate_lists(subjects, scope, map)
78
+ work = subjects.zip(lists).reject { |_, list| list.empty? }
79
+ return [] if work.empty?
80
+
81
+ runnable_subjects, runnable_lists = work.transpose
82
+ begin
83
+ warn_unless_mutants_visible(runner, runnable_subjects.first)
84
+ rescue BudgetPhaseTimeout => e
85
+ return budget_phase_stop(e, subjects)
86
+ end
87
+ run_candidates(runnable_subjects, runnable_lists, scheduler, nil, ignore_list)
88
+ ensure
89
+ runner.close
90
+ end
91
+
92
+ # Warn loudly when the command cannot see mutants at all: an
93
+ # anchored or never-loaded file reads as 100 % survivors (#441).
94
+ def warn_unless_mutants_visible(runner, subject)
95
+ warning = runner.visibility_check(subject: subject, deadline: preflight_deadline,
96
+ budget_deadline: budget_deadline)
97
+ @diagnostics.warn(warning) if warning
98
+ end
99
+
100
+ def external_command? = !config["test_command"].to_s.empty?
101
+
102
+ # The preflight's absolute monotonic deadline: its own
103
+ # baseline_timeout slice, or the run budget's end, whichever is
104
+ # sooner. Absolute, so the tree copy before admission cannot eat the
105
+ # budget while the command still gets a full timeout (#305 Architect).
106
+ def preflight_deadline
107
+ own = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config["baseline_timeout"]
108
+ deadline = budget_deadline
109
+ deadline ? [own, deadline].min : own
110
+ end
111
+
112
+ def external_runner = Execution::ExternalRunner.new(command: config["test_command"], root: @root)
113
+ end
114
+ end
115
+ end
@@ -15,25 +15,36 @@ module Saboteur
15
15
  # same: the method's source, the whole file the method lives in (so a
16
16
  # constant or helper changed elsewhere in that file is seen), the mutant,
17
17
  # the set of covering tests, the content of those tests' files, the
18
- # lockfile, the run's inputs (config, saboteur version). Only killed, survived
19
- # and timeout are stored;
18
+ # lockfile, the run's inputs (config, saboteur version), and every source
19
+ # file a covering test touched (a method it calls, a constant defined
20
+ # elsewhere - #197). Only killed, survived and timeout are stored;
20
21
  # errors and skips always run again.
21
22
  #
22
- # Known hole, the reason this is opt-in: a change in another file that
23
- # this method uses (a method it calls, a constant defined elsewhere) is
24
- # not seen, so a stored verdict may be stale after it.
23
+ # When the dependency set is unknown - no coverage map, or an empty one -
24
+ # every source file is watched instead: reuse turns conservative, never
25
+ # stale.
25
26
  class ResultCache
26
27
  FORMAT = 1
27
28
  STORED = %i[killed survived timeout].freeze
28
29
  LOCKFILES = %w[Gemfile.lock gems.locked].freeze
30
+ # Dependency declarations themselves, so the no-lockfile library
31
+ # shape (bundler's own convention: libraries gitignore the lockfile)
32
+ # still invalidates on a dependency edit (#408).
33
+ MANIFESTS = %w[Gemfile gems.rb].freeze
34
+
35
+ SOURCE_EXCLUDES = %w[.git vendor node_modules].freeze
29
36
 
30
37
  # suite_digest: SuiteFiles#digest, so an edit to a spec helper or
31
38
  # support file invalidates every verdict.
32
- def initialize(path:, root:, inputs:, suite_digest: "")
39
+ def initialize(path:, root:, inputs:, suite_digest: "", coverage: nil, fallback_files: [])
33
40
  @path = path
34
41
  @root = root
35
42
  @inputs = inputs
36
43
  @suite_digest = suite_digest
44
+ # Engine always passes the built map; a missing one behaves as an
45
+ # empty one, which watches every fallback and child-loaded source (#197).
46
+ @coverage = coverage || Selection::CoverageMap.new(lines: {}, tests: [])
47
+ @fallback_files = fallback_files.map { |file| File.expand_path(file, root) }.sort
37
48
  @entries = load
38
49
  @file_digests = {}
39
50
  end
@@ -65,6 +76,16 @@ module Saboteur
65
76
 
66
77
  private
67
78
 
79
+ # The current project source files, relative and sorted. A file added
80
+ # after the map was built changes the set and invalidates every stored
81
+ # verdict, whether or not any watched file references it yet.
82
+ def source_set
83
+ @source_set ||= Dir.glob("**/*.rb", base: @root)
84
+ .reject { |rel| SOURCE_EXCLUDES.include?(rel.split("/").first) }
85
+ .sort
86
+ .join("\0")
87
+ end
88
+
68
89
  def load
69
90
  data = JSON.parse(Text.utf8_strict(File.read(@path)))
70
91
  data["format"] == FORMAT ? data["entries"] : {}
@@ -78,12 +99,33 @@ module Saboteur
78
99
 
79
100
  def fingerprint(subject, tests)
80
101
  digest = Digest::SHA256.new
81
- digest << VERSION << "\0" << @inputs << "\0" << @suite_digest << "\0" << subject.source << "\0"
102
+ digest << [VERSION, @inputs, @suite_digest, source_set, subject.source].join("\0") << "\0"
82
103
  # A hex digest has a fixed width, so it needs no separator after it.
83
104
  digest << Digest::SHA256.hexdigest(subject.file_source)
105
+ digest_tests(digest, tests)
106
+ hash_dependency_inputs(digest)
107
+ digest.hexdigest
108
+ end
109
+
110
+ def digest_tests(digest, tests)
84
111
  tests.sort.each { |test| digest << test << "\0" << file_digest(test_file(test)) << "\0" }
112
+ dependencies(tests).each { |dep| digest << dep << "\0" << file_digest(dep) << "\0" }
85
113
  LOCKFILES.each { |name| digest << file_digest(File.join(@root, name)) << "\0" }
86
- digest.hexdigest
114
+ end
115
+
116
+ # Files a change in which can flip the verdict (#197): every source
117
+ # file a covering test touched, from the run's coverage map, plus the
118
+ # loaded sources the map never observed (#344, #353).
119
+ def dependencies(tests)
120
+ (@coverage.files_for(tests) | @coverage.boot_files | unobserved_files).sort
121
+ end
122
+
123
+ # Loaded sources the map never observed (no line, method, or load
124
+ # evidence): coverage cannot say whether they matter, so a change in
125
+ # any of them must invalidate (#344).
126
+ def unobserved_files
127
+ @unobserved_files ||=
128
+ (@fallback_files | @coverage.loaded_files).select { |file| @coverage.empty_for?([file]) }
87
129
  end
88
130
 
89
131
  # RSpec ids name their file: "./spec/a_spec.rb[1:2]".
@@ -91,6 +133,21 @@ module Saboteur
91
133
  File.expand_path(test[/\A(.+?\.rb)(?:\[|:|\z)/, 1] || test, @root)
92
134
  end
93
135
 
136
+ def hash_dependency_inputs(digest)
137
+ dependency_files.each { |path| digest << path.delete_prefix("#{@root}/") << "\0" << file_digest(path) << "\0" }
138
+ end
139
+
140
+ # Manifests and gemspecs anywhere below the root, since a monorepo
141
+ # sub-gem carries its own dependency declarations (#561); keyed
142
+ # root-relative so same-named sub-gem files cannot collide. Vendored
143
+ # installs and dependencies' own checkouts hold gemspecs the project
144
+ # does not declare, so the scan mirrors RunFingerprint's exclusions.
145
+ def dependency_files
146
+ @dependency_files ||= Dir[File.join(@root, "**", "{#{MANIFESTS.join(',')},*.gemspec}")].reject do |path|
147
+ path.delete_prefix("#{@root}/").split("/").any? { |part| RunFingerprint::EXCLUDED_DIRECTORIES.include?(part) }
148
+ end
149
+ end
150
+
94
151
  def file_digest(path)
95
152
  @file_digests[path] ||= File.file?(path) ? Digest::SHA256.file(path).hexdigest : "missing"
96
153
  end
@@ -35,12 +35,42 @@ module Saboteur
35
35
 
36
36
  # Absolute monotonic deadline shared with the schedulers (forked
37
37
  # workers inherit it): a subject's remaining mutants stop once it
38
- # passes, so the overshoot after a spent budget stays under one
39
- # mutant deadline.
38
+ # passes. New mutation work never overshoots more than the fork in
39
+ # flight; a subject the deadline cut mid-list still confirms the
40
+ # kills it already measured (#588), and those checks and retries
41
+ # fork uncapped - the overshoot is bounded by the subject's
42
+ # distinct killing sequences times their full timeout.
40
43
  def budget_deadline
41
44
  config["max_duration"] && (@run_started + config["max_duration"])
42
45
  end
43
46
 
47
+ # Once the baselines are timed, a cold coverage build is the last
48
+ # unpriced phase before the mutants: it costs about one more full pass
49
+ # and keeps nothing when the budget cuts it off, so a resume starts it
50
+ # from zero again. Refuse up front when the remaining budget cannot
51
+ # cover that pass, naming the shortfall and the remedies (#427).
52
+ def refuse_unaffordable_coverage_build(store, files, baseline_elapsed = @baseline_elapsed,
53
+ completed_passes = @completed_baseline_passes)
54
+ return unless config["max_duration"]
55
+ return if store.reusable?(files)
56
+ return if completed_passes.to_i.zero?
57
+
58
+ per_pass = baseline_elapsed / completed_passes
59
+ remaining = config["max_duration"] - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started)
60
+ # A spent budget is the existing stop path's news, and an untimed
61
+ # (zero-second) baseline carries no estimate to project from.
62
+ return if remaining <= 0 || per_pass <= 0
63
+ return if remaining >= per_pass
64
+
65
+ needed = per_pass - remaining
66
+ raise BudgetPhaseTimeout,
67
+ "the --max-duration budget cannot cover the coverage map build: baseline passes took about " \
68
+ "#{per_pass.round}s each, only #{remaining.round(1)}s remain, and a cold build needs about one " \
69
+ "more full pass and keeps nothing when it is cut off. Re-run with --max-duration about " \
70
+ "#{needed.round}s higher, fewer --baseline-passes, or once without --max-duration so the " \
71
+ "coverage cache is built for later --incremental runs"
72
+ end
73
+
44
74
  # Boot and baselines can spend the budget by themselves: stop before
45
75
  # an expensive cold coverage build and mark every selected subject
46
76
  # unrun.