saboteur 0.6.0 → 0.7.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +166 -40
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +49 -4
  8. data/lib/saboteur/cli/help_options.rb +16 -0
  9. data/lib/saboteur/cli/presets.rb +36 -0
  10. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  11. data/lib/saboteur/cli/run_reporting.rb +74 -5
  12. data/lib/saboteur/cli.rb +67 -46
  13. data/lib/saboteur/config.rb +66 -11
  14. data/lib/saboteur/crash_report.rb +154 -0
  15. data/lib/saboteur/custom_map.rb +3 -1
  16. data/lib/saboteur/doctor.rb +40 -1
  17. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  18. data/lib/saboteur/engine/boot.rb +12 -1
  19. data/lib/saboteur/engine/canary.rb +41 -10
  20. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  21. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  22. data/lib/saboteur/engine/coverage_store.rb +48 -6
  23. data/lib/saboteur/engine/diagnostics.rb +23 -2
  24. data/lib/saboteur/engine/diff_scope.rb +24 -5
  25. data/lib/saboteur/engine/distribution.rb +124 -8
  26. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  27. data/lib/saboteur/engine/result_cache.rb +2 -1
  28. data/lib/saboteur/engine/run_budget.rb +63 -0
  29. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  30. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  31. data/lib/saboteur/engine/scheduler.rb +114 -18
  32. data/lib/saboteur/engine/subject_selection.rb +84 -5
  33. data/lib/saboteur/engine/suite_files.rb +3 -1
  34. data/lib/saboteur/engine/test_guess.rb +145 -22
  35. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  36. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  37. data/lib/saboteur/engine/verdict_check.rb +54 -18
  38. data/lib/saboteur/engine.rb +166 -95
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/runner.rb +179 -17
  41. data/lib/saboteur/execution/sandbox.rb +6 -1
  42. data/lib/saboteur/execution/supervisor.rb +18 -3
  43. data/lib/saboteur/execution/worker_pool.rb +87 -7
  44. data/lib/saboteur/help.rb +55 -11
  45. data/lib/saboteur/ignore_list.rb +11 -1
  46. data/lib/saboteur/install.rb +4 -3
  47. data/lib/saboteur/integration/base.rb +7 -2
  48. data/lib/saboteur/integration/rspec.rb +40 -4
  49. data/lib/saboteur/mutation_source/builtin.rb +20 -11
  50. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  51. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  52. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  53. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  54. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  55. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  56. data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
  57. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  58. data/lib/saboteur/preview.rb +2 -1
  59. data/lib/saboteur/reporter/agent_commands.rb +38 -13
  60. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  61. data/lib/saboteur/reporter/next_steps.rb +14 -4
  62. data/lib/saboteur/reporter/score_history.rb +5 -1
  63. data/lib/saboteur/reporter/stryker.rb +13 -4
  64. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  65. data/lib/saboteur/reporter/terminal.rb +34 -10
  66. data/lib/saboteur/result.rb +22 -7
  67. data/lib/saboteur/selection/coverage_map.rb +2 -1
  68. data/lib/saboteur/selection/git_diff.rb +15 -5
  69. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  70. data/lib/saboteur/subject.rb +11 -0
  71. data/lib/saboteur/text.rb +42 -0
  72. data/lib/saboteur/version.rb +1 -1
  73. data/lib/saboteur.rb +16 -1
  74. metadata +17 -2
@@ -22,11 +22,14 @@ module Saboteur
22
22
  @baseline_timeout = baseline_timeout
23
23
  end
24
24
 
25
+ # Why a result the budget never let the check run is not a verdict.
26
+ BUDGET_REASON = "the --max-duration budget stopped the verdict check before it could run"
27
+
25
28
  def call(results)
26
29
  fresh = results.reject(&:reused?)
27
- failing_runs = failing_sequences(fresh.select { |result| named_kill?(result) })
28
- failing = failing_claims(fresh.select { |result| unnamed_kill?(result) })
29
- results.map { |result| checked(result, failing_runs, failing) }
30
+ failing_runs, unchecked_runs = check_sequences(fresh.select { |result| named_kill?(result) })
31
+ failing, unchecked = check_claims(fresh.select { |result| unnamed_kill?(result) })
32
+ results.map { |result| checked(result, failing_runs, failing, unchecked_runs, unchecked) }
30
33
  end
31
34
 
32
35
  private
@@ -47,31 +50,64 @@ module Saboteur
47
50
  position ? result.covered_by.first(position + 1) : [result.killed_by.first]
48
51
  end
49
52
 
50
- # Runs each distinct kill sequence once. Returns those that fail, each
51
- # with why (the failure message, when there is one).
52
- def failing_sequences(kills)
53
- kills.map { |result| sequence(result) }.uniq.each_with_object({}) do |tests, failing|
53
+ # Runs each distinct kill sequence once. Returns [failing, unchecked]:
54
+ # failing maps a sequence to why it failed unchanged, unchecked maps
55
+ # the sequences the budget never let run to why - an unverified re-run
56
+ # is not a rejection (#296).
57
+ def check_sequences(kills)
58
+ failing = {}
59
+ unchecked = {}
60
+ kills.map { |result| sequence(result) }.uniq.each do |tests|
54
61
  outcome = @run_unchanged.call(tests, @baseline_timeout)
55
- failing[tests] = outcome.message unless outcome.status == :survived
62
+ if outcome.status == :unverified
63
+ unchecked[tests] = outcome.message
64
+ elsif outcome.status != :survived
65
+ failing[tests] = outcome.message
66
+ end
56
67
  end
68
+ [failing, unchecked]
57
69
  end
58
70
 
59
- # Kills with no failing test named. Returns those that fail unchanged.
60
- def failing_claims(claims)
61
- claims.reject do |result|
62
- @run_unchanged.call(result.covered_by, @baseline_timeout).status == :survived
71
+ # Kills with no failing test named, checked on all their tests.
72
+ # Returns [failing, unchecked]: failing maps a rejected result to why
73
+ # it failed unchanged, unchecked maps a result the budget never let
74
+ # run to why.
75
+ def check_claims(claims)
76
+ failing = {}
77
+ unchecked = {}
78
+ claims.each do |result|
79
+ outcome = @run_unchanged.call(result.covered_by, @baseline_timeout)
80
+ if outcome.status == :unverified
81
+ unchecked[result] = outcome.message
82
+ elsif outcome.status != :survived
83
+ failing[result] = outcome.message
84
+ end
63
85
  end
86
+ [failing, unchecked]
64
87
  end
65
88
 
66
- def checked(result, failing_runs, failing)
89
+ def checked(result, failing_runs, failing, unchecked_runs, unchecked)
67
90
  return result if result.reused?
91
+ return checked_named(result, failing_runs, unchecked_runs) if named_kill?(result)
92
+
93
+ if failing.key?(result)
94
+ why = failing[result]
95
+ result.as_error("tests also fail with the method unchanged#{": #{why}" if why}", rejected_kill: true)
96
+ elsif unchecked.key?(result)
97
+ result.as_unverified(unchecked[result] || BUDGET_REASON)
98
+ else
99
+ result
100
+ end
101
+ end
68
102
 
69
- if named_kill?(result) && failing_runs.key?(sequence(result))
70
- why = failing_runs[sequence(result)]
71
- result.as_error("killing test #{sequence(result).last} also fails with the method unchanged" \
103
+ def checked_named(result, failing_runs, unchecked_runs)
104
+ tests = sequence(result)
105
+ if failing_runs.key?(tests)
106
+ why = failing_runs[tests]
107
+ result.as_error("killing test #{tests.last} also fails with the method unchanged" \
72
108
  "#{": #{why}" if why}", rejected_kill: true)
73
- elsif failing.include?(result)
74
- result.as_error("tests also fail with the method unchanged", rejected_kill: true)
109
+ elsif unchecked_runs.key?(tests)
110
+ result.as_unverified(unchecked_runs[tests] || BUDGET_REASON)
75
111
  else
76
112
  result
77
113
  end
@@ -6,25 +6,31 @@ require "json"
6
6
  require_relative "replay"
7
7
  require_relative "mutation_source/hazard_guard"
8
8
  require_relative "baseline/run_evidence"
9
+ require_relative "engine/early_stop_recording"
10
+ require_relative "engine/run_budget"
11
+ require_relative "engine/thread_warnings"
9
12
 
10
13
  module Saboteur
11
14
  # A single-use instance runs one pipeline: boot the target, select subjects, fetch the
12
15
  # coverage map, then schedule each subject's mutants. The parts live in
13
16
  # engine/; this class only wires them together.
14
17
  class Engine
18
+ include EarlyStopRecording
19
+ include RunBudget
20
+
15
21
  # Everything besides sources and tests that can change a verdict.
16
22
  VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
17
23
  timeout_factor timeout_min
18
24
  max_memory_mb max_processes seed tests whole_file].freeze
19
25
 
20
26
  attr_reader :config, :hooks
21
- # The specs --guess-tests narrowed the run to, or nil when it did not.
27
+ # The specs inference narrowed the run to, or nil when it did not.
22
28
  attr_reader :guessed_tests
23
29
 
24
- def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [])
30
+ def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [], verbose: false)
25
31
  @config = config
26
32
  @hooks = Hooks.new
27
- @diagnostics = Diagnostics.new(err)
33
+ @diagnostics = Diagnostics.new(err, verbose: verbose)
28
34
  @root = File.realpath(config.root)
29
35
  @skipped_subjects = {}
30
36
  @replay = Replay.new(digest: digest) if digest
@@ -38,12 +44,16 @@ module Saboteur
38
44
  end
39
45
 
40
46
  def call
41
- Boot.refuse_recursion!
42
- refuse_fail_fast_evidence
47
+ preflight!
43
48
  started = monotonic
44
- guess_tests
45
- announce_operators
49
+ start_budget_clock(started)
50
+ # Capture evidence inputs before inference narrows the test list: the
51
+ # fingerprint must match what the baseline workflow recomputes from the
52
+ # declared config, and the inferred specs are pinned by the hashed
53
+ # source files anyway.
46
54
  @run_evidence = Baseline::RunEvidence.new(config: config, extra_files: @evidence_files) if @capture_evidence
55
+ select_tests
56
+ announce_operators
47
57
  ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
48
58
  warn_custom_map
49
59
  boot = Boot.new(config, hooks)
@@ -52,29 +62,31 @@ module Saboteur
52
62
  @rails = boot.rails
53
63
  subjects, scope = select_subjects(files)
54
64
  results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
55
- @diagnostics.warnings.concat(ignore_list.apply(results))
65
+ @diagnostics.warnings.concat(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
56
66
  build_report(results, subjects, scope, started)
57
67
  ensure
58
- # The run boundary for the per-worker databases: cleanup runs however
59
- # the call ends - a boot that raised partway through, a baseline or
60
- # coverage failure, no subjects, no runnable work - so a host that
61
- # embeds Engine and calls it repeatedly never holds copies or
62
- # snapshots until process exit. cleanup_workers is idempotent; the
63
- # Rails profile's at_exit handler stays as the fallback for an
64
- # abrupt process exit.
65
- safely_cleanup_workers(boot.rails)
68
+ # Cleanup runs however the call ends - a partway boot failure, no
69
+ # subjects, no runnable work - so a host embedding Engine never
70
+ # holds worker copies until process exit. Idempotent; the Rails
71
+ # profile's at_exit handler stays the fallback for abrupt exit.
72
+ begin
73
+ @coverage_guard&.resume # an embedded host gets its coverage back (#254)
74
+ ensure
75
+ safely_cleanup_workers(boot.rails)
76
+ end
66
77
  end
67
78
  end
68
79
 
69
- def warn_custom_map
70
- @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
80
+ def preflight!
81
+ Boot.refuse_recursion!
82
+ refuse_fail_fast_evidence
71
83
  end
72
84
 
85
+ def warn_custom_map = @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
86
+
73
87
  # Stale entries and duplicate literals never fail a run; they warn so the
74
88
  # map does not rot silently. doctor fails on stale keys.
75
- def load_custom_map
76
- @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
77
- end
89
+ def load_custom_map = @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
78
90
 
79
91
  def custom_map_warnings(map)
80
92
  path = config.path("custom_map")
@@ -86,29 +98,80 @@ module Saboteur
86
98
 
87
99
  private
88
100
 
89
- # --guess-tests with --since: scope the run to the specs found next to
90
- # the changed files, as --test would, but only when each changed file
91
- # has one. Otherwise the whole suite runs, as without the flag.
92
- def guess_tests
93
- return unless config["guess_tests"]
94
-
95
- guess = TestGuess.new(config).call
96
- message =
97
- if guess.changed.zero?
98
- "--guess-tests: no changed source file to guess specs for, so the whole suite runs"
99
- elsif guess.unmatched.any?
100
- "--guess-tests: no spec found for #{guess.unmatched.join(', ')}, so the whole suite runs " \
101
- "(name specs after their source files or pass --test)"
101
+ # Inferred test selection, the default since 0.7.0: scope the run to the
102
+ # specs found next to the mutated files, as --test would. With --since
103
+ # only files whose changed lines carry a mutation subject need a spec;
104
+ # without it every included file with a subject does. A file that needs
105
+ # a spec and has none stops the run before boot, rather than silently
106
+ # narrowing the source scope or widening the test run to the whole
107
+ # suite. --all and c.test_selection = :all keep the whole suite.
108
+ # A replay keeps its recorded tests, and a --subject run infers only
109
+ # from the named subjects' files, so one unrelated spec-less file can
110
+ # never block a one-method run.
111
+ def select_tests
112
+ return unless config["tests"].empty?
113
+ return if @replay || config["all"] || config["test_selection"] == "all"
114
+
115
+ subjects = config["subjects"]
116
+ return select_subject_tests(subjects) if subjects.any?
117
+
118
+ guess = TestGuess.new(config, diagnostics: @diagnostics).call
119
+ @diagnostics.inform(guess.subjectless_message(since: config["since"])) if guess.subjectless.any?
120
+ raise ConfigError, guess.unmatched_message if guess.unmatched.any?
121
+
122
+ @diagnostics.announce(selection_message(guess))
123
+ end
124
+
125
+ # A --subject run infers only from the files holding the named subjects:
126
+ # an unrelated spec-less file must not block a one-method run. A subject
127
+ # file without a conventional spec stops the run before boot, like any
128
+ # other unmatched file. With --since only named subjects inside the diff
129
+ # scope count: an unchanged, comment-only or otherwise pruned subject
130
+ # needs no spec and cannot stop the run either.
131
+ def select_subject_tests(patterns)
132
+ files = SubjectSelection.files_matching(@root, config.included_files, patterns)
133
+ guess = TestGuess.new(config, diagnostics: @diagnostics).call_for(files, patterns)
134
+ # No file in scope: the subjects are unknown (named later, in
135
+ # selection) or --since left every named subject outside the diff, so
136
+ # the run has nothing to mutate and needs no inferred spec.
137
+ return if guess.changed.zero?
138
+
139
+ raise ConfigError, guess.unmatched_message if guess.unmatched.any?
140
+
141
+ @diagnostics.announce(scope_to_guesses(guess))
142
+ end
143
+
144
+ def selection_message(guess)
145
+ if guess.changed.zero?
146
+ target = config["since"] ? "no changed source file" : "no source file"
147
+ "inferred specs: #{target} to infer a spec for, so the whole suite runs"
148
+ elsif guess.specs.empty?
149
+ if config["since"]
150
+ "inferred specs: no changed file has a mutation subject on its changed lines, " \
151
+ "so there is nothing to mutate"
102
152
  else
103
- # The guesses now stand as explicit tests, like --test.
104
- @config = Config.new(config.to_h.merge("tests" => guess.specs, "guess_tests" => false), root: config.root)
105
- @guessed_tests = guess.specs
106
- "--guess-tests: running only #{guess.specs.join(', ')}. A spec that reaches a changed file only " \
107
- "through another file does not run, so a mutant only it would kill can survive, and a line " \
108
- "no guessed spec executes is reported as NoCoverage"
153
+ "inferred specs: no source file has a mutation subject, so there is nothing to mutate"
109
154
  end
110
- @diagnostics.warn(message)
111
- @diagnostics.inform(message)
155
+ else
156
+ scope_to_guesses(guess)
157
+ end
158
+ end
159
+
160
+ # Stop before boot: name the spec-less files and the ways out. The
161
+ # hinted spec paths are the conventional ones - they do not exist (that
162
+ # is why the files are unmatched), so the message never prints them as
163
+ # a runnable --test command, and it never reconstructs the source
164
+ # scope: the user appends --test or --all to their own command. The
165
+ # example is still shell-safe: the ref is user input, so it is quoted
166
+ # and pinned to the flag with = (a ref like "-x" must not become a
167
+ # switch).
168
+ def scope_to_guesses(guess)
169
+ # The guesses now stand as explicit tests, like --test. guess_tests is
170
+ # dropped rather than set to false: an explicit false is the deprecated
171
+ # opt-out and would warn.
172
+ @config = config.with({ "tests" => guess.specs }, except: ["guess_tests"])
173
+ @guessed_tests = guess.specs
174
+ guess.scope_message(since: config["since"])
112
175
  end
113
176
 
114
177
  # The run's immutable report, built once the last result is in.
@@ -124,7 +187,7 @@ module Saboteur
124
187
  omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
125
188
  hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
126
189
  matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
127
- operator_policy: config.operator_policy)
190
+ operator_policy: config.operator_policy, discovery_failures: @diagnostics.discovery_failures)
128
191
  end
129
192
 
130
193
  def complete_evidence(results, subjects)
@@ -149,10 +212,13 @@ module Saboteur
149
212
  end
150
213
 
151
214
  def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
152
- warn_about_unscoped_suite(integration)
153
- check_baseline(integration)
154
- map = CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
155
- supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
215
+ store = coverage_store(integration, load_lines)
216
+ full_passes = config["baseline_passes"] + (store.reusable?(files) ? 0 : 1)
217
+ warn_unscoped(integration, full_passes)
218
+ check_baseline(integration, full_passes)
219
+ return [] if abandon_for_budget?(subjects)
220
+
221
+ map = store.fetch(files)
156
222
  inform_about_run(subjects, map)
157
223
  cache = result_cache
158
224
  scheduler = scheduler_for(map, integration, cache)
@@ -169,14 +235,17 @@ module Saboteur
169
235
  results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
170
236
  cache&.save
171
237
  results
238
+ rescue BudgetPhaseTimeout => e
239
+ budget_phase_stop(e, subjects)
172
240
  end
173
241
 
174
242
  def run_candidates(subjects, lists, scheduler, cache, ignore_list)
175
243
  @diagnostics.start_progress(lists.sum(&:size))
176
244
  distribution = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
177
245
  cache: cache, on_start: method(:start_worker),
178
- after_subject: canary_check, stop_after: fail_fast_check(ignore_list))
179
- distribution.run(subjects, lists).tap { record_early_stop(distribution.unreached) }
246
+ after_subject: after_subject_hook, stop_after: stop_checks(ignore_list),
247
+ halt_check: config["max_duration"] ? method(:budget_exceeded?) : nil)
248
+ distribution.run(subjects, lists).tap { record_early_stop(distribution) }
180
249
  ensure
181
250
  @diagnostics.finish_progress
182
251
  end
@@ -189,16 +258,15 @@ module Saboteur
189
258
  raise ConfigError, "fail_fast cannot be used with baseline evidence: it certifies the whole selection"
190
259
  end
191
260
 
192
- # Subjects that were selected but never ran, with candidates, are part of
193
- # the report: the verdict covers less than the selection.
194
- def record_early_stop(unreached)
195
- return if unreached.empty?
196
-
197
- @early_stop = EarlyStop.new(reason: "fail_fast", unreached: unreached.map(&:id))
198
- end
199
-
200
261
  # --fail-fast: stop after the first subject that has a survivor the
201
262
  # ignore list does not accept. Granularity is one subject.
263
+ # --fail-fast and --max-duration both stop before the next subject; the
264
+ # distribution records whatever they leave unreached.
265
+ def stop_checks(ignore_list)
266
+ checks = [fail_fast_check(ignore_list), ->(_results) { budget_exceeded? }].compact
267
+ ->(results) { checks.any? { |check| check.call(results) } }
268
+ end
269
+
202
270
  def fail_fast_check(ignore_list)
203
271
  return nil unless config["fail_fast"]
204
272
 
@@ -211,38 +279,40 @@ module Saboteur
211
279
 
212
280
  def inform_about_run(subjects, map)
213
281
  @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
214
- warn_about_threads
282
+ ThreadWarnings.warn(@diagnostics)
215
283
  end
216
284
 
217
- # A --since run without --test runs the loaded suite twice for the
218
- # baselines; a cold coverage cache adds one more pass. Say so
219
- # before those hours pass, with the way to scope it.
220
- def warn_about_unscoped_suite(integration)
221
- return unless config["since"] && config["tests"].empty?
285
+ def coverage_store(integration, load_lines)
286
+ CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
287
+ supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
288
+ timeout_cap: method(:budget_capped_timeout))
289
+ end
222
290
 
223
- message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
224
- "(#{integration.test_files.size} spec files). " \
225
- "Pass --test to run only the specs you name: faster, but a mutant of a changed method is then killed " \
226
- "only by those specs, so specs elsewhere that call this code are not run. " \
227
- "--guess-tests does the same with the specs named after the changed files, " \
228
- "a guess that can miss specs elsewhere."
229
- @diagnostics.warn(message)
230
- @diagnostics.inform(message)
291
+ def check_baseline(integration, full_passes)
292
+ guard = BaselineGuard.new(config: config, integration: integration, hooks: hooks,
293
+ supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
294
+ full_passes: full_passes, timeout_cap: method(:budget_capped_timeout),
295
+ between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil)
296
+ guard.call
297
+ # Stability is the order comparison between completed checks: the cold
298
+ # coverage build is not a baseline pass, and a budget break shortens
299
+ # the loop, so certify only what actually ran.
300
+ @stable_suite = guard.completed_passes >= 2
231
301
  end
232
302
 
233
- def check_baseline(integration)
234
- BaselineGuard.new(config: config, integration: integration, hooks: hooks,
235
- supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
236
- @stable_suite = true
303
+ # A --id replay targets one known mutant; the whole-suite cost summary
304
+ # and its --test lecture do not apply.
305
+ def warn_unscoped(integration, full_passes)
306
+ UnscopedSuiteWarning.call(config:, diagnostics: @diagnostics, integration:, full_passes:) unless @replay
237
307
  end
238
308
 
239
309
  def scheduler_for(map, integration, cache)
240
310
  reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
241
311
  runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
242
312
  supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"],
243
- sandbox: sandbox_hook)
313
+ sandbox: sandbox_hook, canary: canary)
244
314
  Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
245
- cache: cache)
315
+ cache: cache, deadline: budget_deadline)
246
316
  end
247
317
 
248
318
  # The sandbox hook for each mutant child (#152); the policy and its
@@ -287,8 +357,17 @@ module Saboteur
287
357
 
288
358
  # Checks the tree after each subject's mutants; a vanished file means a
289
359
  # mutant deleted it, so abort with the most recent mutant's digest.
360
+ # Per finished subject: the canary, then the --verbose marks line that
361
+ # makes a survivor attributable to its method mid-run (#262).
362
+ def after_subject_hook
363
+ canary = canary_check
364
+ lambda do |subject, results|
365
+ canary.call(subject, results)
366
+ @diagnostics.subject_progress(subject, results)
367
+ end
368
+ end
369
+
290
370
  def canary_check
291
- canary = Canary.new(@root, cache_dir: config["cache_dir"])
292
371
  lambda do |subject, results|
293
372
  last = results.reject(&:reused?).last || results.last
294
373
  digest = last&.mutation&.digest(subject_start: subject.start_byte,
@@ -297,6 +376,9 @@ module Saboteur
297
376
  end
298
377
  end
299
378
 
379
+ # Shared with the runner so the hazard report wins over a log loss (#283).
380
+ def canary = (@canary ||= Canary.new(@root, cache_dir: config["cache_dir"]))
381
+
300
382
  def candidate_lists(subjects, scope, map)
301
383
  return [@replay_candidate] if @replay
302
384
 
@@ -305,6 +387,7 @@ module Saboteur
305
387
  limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
306
388
  subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
307
389
  @skipped_subjects = selection.skipped_subjects
390
+ @unscoped_digests = selection.unscoped_digests
308
391
  if @run_evidence
309
392
  @run_evidence.plan(subjects, subjects.map { |subject| selection.selected.fetch(subject) })
310
393
  @evidence_planned = true
@@ -333,19 +416,6 @@ module Saboteur
333
416
  end
334
417
  end
335
418
 
336
- # Threads do not survive fork, so name them: with the name the owner
337
- # gave the thread, else the first frame of its backtrace, so the user
338
- # can tell whether they matter (a connection pool reaper, Sidekiq, an
339
- # SDK's background flusher).
340
- def warn_about_threads
341
- others = Thread.list - [Thread.current]
342
- return if others.empty?
343
-
344
- names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
345
- list = names.join(", ")
346
- @diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
347
- end
348
-
349
419
  # The lines that ran while the app and spec files loaded: the executable
350
420
  # counts from its first line, so this snapshot holds the whole boot.
351
421
  # Read here, in the parent: a forked child does not inherit Coverage
@@ -377,22 +447,22 @@ module Saboteur
377
447
  # Loads the target and stops coverage, returning what the rest of the
378
448
  # run needs from that boot.
379
449
  def boot_target(boot)
450
+ @diagnostics.inform("loading the application")
380
451
  integration = boot.load_target
381
452
  files = config.included_files
382
453
  [integration, files, suspend_coverage(files)]
383
454
  end
384
455
 
385
456
  def suspend_coverage(files)
386
- Coverage.suspend if Coverage.state == :running
457
+ (@coverage_guard = CoverageGuard.new(@diagnostics)).suspend
387
458
  capture_load_lines(files)
388
459
  end
389
460
 
390
- def monotonic
391
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
392
- end
461
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
393
462
  end
394
463
  end
395
464
 
465
+ require_relative "engine/coverage_guard"
396
466
  require_relative "engine/diagnostics"
397
467
  require_relative "engine/boot"
398
468
  require_relative "engine/subject_selection"
@@ -402,6 +472,7 @@ require_relative "engine/test_guess"
402
472
  require_relative "engine/sandbox_policy"
403
473
  require_relative "engine/coverage_store"
404
474
  require_relative "engine/baseline_guard"
475
+ require_relative "engine/unscoped_suite_warning"
405
476
  require_relative "engine/bytecode_equivalence"
406
477
  require_relative "engine/candidates"
407
478
  require_relative "engine/candidate_selection"
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
+ require_relative "../text"
5
6
 
6
7
  module Saboteur
7
8
  module Execution
@@ -31,7 +32,7 @@ module Saboteur
31
32
  # Events in order; lines that are not this protocol's are ignored.
32
33
  def parse(payload)
33
34
  payload.each_line.filter_map do |line|
34
- message = JSON.parse(line)
35
+ message = JSON.parse(Text.utf8_strict(line))
35
36
  message if message.is_a?(Hash) && message["v"] == VERSION
36
37
  rescue JSON::ParserError
37
38
  nil