saboteur 0.5.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -0
  3. data/README.md +202 -42
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/comparison.rb +1 -0
  6. data/lib/saboteur/baseline/evidence.rb +11 -1
  7. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  8. data/lib/saboteur/baseline/workflow.rb +2 -1
  9. data/lib/saboteur/baseline_health.rb +40 -6
  10. data/lib/saboteur/cli/exit_status.rb +49 -4
  11. data/lib/saboteur/cli/help_options.rb +16 -0
  12. data/lib/saboteur/cli/presets.rb +36 -0
  13. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  14. data/lib/saboteur/cli/run_reporting.rb +75 -6
  15. data/lib/saboteur/cli.rb +80 -48
  16. data/lib/saboteur/config.rb +88 -13
  17. data/lib/saboteur/crash_report.rb +154 -0
  18. data/lib/saboteur/custom_map.rb +3 -1
  19. data/lib/saboteur/doctor.rb +64 -2
  20. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  21. data/lib/saboteur/engine/boot.rb +12 -1
  22. data/lib/saboteur/engine/canary.rb +41 -10
  23. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  24. data/lib/saboteur/engine/candidates.rb +4 -1
  25. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  26. data/lib/saboteur/engine/coverage_store.rb +48 -6
  27. data/lib/saboteur/engine/diagnostics.rb +23 -2
  28. data/lib/saboteur/engine/diff_scope.rb +24 -5
  29. data/lib/saboteur/engine/distribution.rb +124 -8
  30. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  31. data/lib/saboteur/engine/result_cache.rb +2 -1
  32. data/lib/saboteur/engine/run_budget.rb +63 -0
  33. data/lib/saboteur/engine/run_fingerprint.rb +5 -1
  34. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  35. data/lib/saboteur/engine/scheduler.rb +114 -18
  36. data/lib/saboteur/engine/subject_selection.rb +84 -5
  37. data/lib/saboteur/engine/suite_files.rb +3 -1
  38. data/lib/saboteur/engine/test_guess.rb +183 -23
  39. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  40. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  41. data/lib/saboteur/engine/verdict_check.rb +54 -18
  42. data/lib/saboteur/engine.rb +175 -96
  43. data/lib/saboteur/execution/protocol.rb +2 -1
  44. data/lib/saboteur/execution/runner.rb +179 -17
  45. data/lib/saboteur/execution/sandbox.rb +6 -1
  46. data/lib/saboteur/execution/supervisor.rb +18 -3
  47. data/lib/saboteur/execution/worker_pool.rb +87 -7
  48. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  49. data/lib/saboteur/frameworks/rails.rb +8 -10
  50. data/lib/saboteur/help.rb +55 -10
  51. data/lib/saboteur/ignore_list.rb +11 -1
  52. data/lib/saboteur/install.rb +4 -3
  53. data/lib/saboteur/integration/base.rb +7 -2
  54. data/lib/saboteur/integration/rspec.rb +40 -4
  55. data/lib/saboteur/mutation_source/builtin.rb +22 -13
  56. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  57. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  58. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  59. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  60. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  61. data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
  62. data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
  63. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  64. data/lib/saboteur/preview.rb +12 -2
  65. data/lib/saboteur/reporter/agent_commands.rb +49 -16
  66. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  67. data/lib/saboteur/reporter/agent_summary.rb +1 -1
  68. data/lib/saboteur/reporter/history_policy.rb +35 -0
  69. data/lib/saboteur/reporter/history_record.rb +4 -5
  70. data/lib/saboteur/reporter/next_steps.rb +14 -4
  71. data/lib/saboteur/reporter/score_history.rb +19 -3
  72. data/lib/saboteur/reporter/stryker.rb +19 -8
  73. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  74. data/lib/saboteur/reporter/terminal.rb +43 -10
  75. data/lib/saboteur/result.rb +25 -7
  76. data/lib/saboteur/selection/coverage_map.rb +2 -1
  77. data/lib/saboteur/selection/git_diff.rb +15 -5
  78. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  79. data/lib/saboteur/subject.rb +11 -0
  80. data/lib/saboteur/text.rb +42 -0
  81. data/lib/saboteur/version.rb +1 -1
  82. data/lib/saboteur.rb +16 -1
  83. metadata +20 -2
@@ -6,24 +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
- VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators arid timeout_factor timeout_min
22
+ VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
23
+ timeout_factor timeout_min
17
24
  max_memory_mb max_processes seed tests whole_file].freeze
18
25
 
19
26
  attr_reader :config, :hooks
20
- # 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.
21
28
  attr_reader :guessed_tests
22
29
 
23
- 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)
24
31
  @config = config
25
32
  @hooks = Hooks.new
26
- @diagnostics = Diagnostics.new(err)
33
+ @diagnostics = Diagnostics.new(err, verbose: verbose)
27
34
  @root = File.realpath(config.root)
28
35
  @skipped_subjects = {}
29
36
  @replay = Replay.new(digest: digest) if digest
@@ -37,11 +44,16 @@ module Saboteur
37
44
  end
38
45
 
39
46
  def call
40
- Boot.refuse_recursion!
41
- refuse_fail_fast_evidence
47
+ preflight!
42
48
  started = monotonic
43
- guess_tests
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.
44
54
  @run_evidence = Baseline::RunEvidence.new(config: config, extra_files: @evidence_files) if @capture_evidence
55
+ select_tests
56
+ announce_operators
45
57
  ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
46
58
  warn_custom_map
47
59
  boot = Boot.new(config, hooks)
@@ -50,29 +62,31 @@ module Saboteur
50
62
  @rails = boot.rails
51
63
  subjects, scope = select_subjects(files)
52
64
  results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
53
- @diagnostics.warnings.concat(ignore_list.apply(results))
65
+ @diagnostics.warnings.concat(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
54
66
  build_report(results, subjects, scope, started)
55
67
  ensure
56
- # The run boundary for the per-worker databases: cleanup runs however
57
- # the call ends - a boot that raised partway through, a baseline or
58
- # coverage failure, no subjects, no runnable work - so a host that
59
- # embeds Engine and calls it repeatedly never holds copies or
60
- # snapshots until process exit. cleanup_workers is idempotent; the
61
- # Rails profile's at_exit handler stays as the fallback for an
62
- # abrupt process exit.
63
- 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
64
77
  end
65
78
  end
66
79
 
67
- def warn_custom_map
68
- @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
80
+ def preflight!
81
+ Boot.refuse_recursion!
82
+ refuse_fail_fast_evidence
69
83
  end
70
84
 
85
+ def warn_custom_map = @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
86
+
71
87
  # Stale entries and duplicate literals never fail a run; they warn so the
72
88
  # map does not rot silently. doctor fails on stale keys.
73
- def load_custom_map
74
- @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
75
- end
89
+ def load_custom_map = @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
76
90
 
77
91
  def custom_map_warnings(map)
78
92
  path = config.path("custom_map")
@@ -84,32 +98,87 @@ module Saboteur
84
98
 
85
99
  private
86
100
 
87
- # --guess-tests with --since: scope the run to the specs found next to
88
- # the changed files, as --test would, but only when each changed file
89
- # has one. Otherwise the whole suite runs, as without the flag.
90
- def guess_tests
91
- return unless config["guess_tests"]
92
-
93
- guess = TestGuess.new(config).call
94
- message =
95
- if guess.changed.zero?
96
- "--guess-tests: no changed source file to guess specs for, so the whole suite runs"
97
- elsif guess.unmatched.any?
98
- "--guess-tests: no spec found for #{guess.unmatched.join(', ')}, so the whole suite runs " \
99
- "(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"
100
152
  else
101
- # The guesses now stand as explicit tests, like --test.
102
- @config = Config.new(config.to_h.merge("tests" => guess.specs, "guess_tests" => false), root: config.root)
103
- @guessed_tests = guess.specs
104
- "--guess-tests: running only #{guess.specs.join(', ')}. A spec that reaches a changed file only " \
105
- "through another file does not run, so a mutant only it would kill can survive, and a line " \
106
- "no guessed spec executes is reported as NoCoverage"
153
+ "inferred specs: no source file has a mutation subject, so there is nothing to mutate"
107
154
  end
108
- @diagnostics.warn(message)
109
- @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"])
110
175
  end
111
176
 
112
177
  # The run's immutable report, built once the last result is in.
178
+ def announce_operators
179
+ @diagnostics.inform("Operators: #{config.operator_policy['effective'].join(', ')}")
180
+ end
181
+
113
182
  def build_report(results, subjects, scope, started)
114
183
  Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
115
184
  invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
@@ -117,7 +186,8 @@ module Saboteur
117
186
  diff_scope: scope, generation_events: @diagnostics.generation_events,
118
187
  omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
119
188
  hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
120
- matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions)
189
+ matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
190
+ operator_policy: config.operator_policy, discovery_failures: @diagnostics.discovery_failures)
121
191
  end
122
192
 
123
193
  def complete_evidence(results, subjects)
@@ -142,10 +212,13 @@ module Saboteur
142
212
  end
143
213
 
144
214
  def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
145
- warn_about_unscoped_suite(integration)
146
- check_baseline(integration)
147
- map = CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
148
- 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)
149
222
  inform_about_run(subjects, map)
150
223
  cache = result_cache
151
224
  scheduler = scheduler_for(map, integration, cache)
@@ -162,14 +235,17 @@ module Saboteur
162
235
  results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
163
236
  cache&.save
164
237
  results
238
+ rescue BudgetPhaseTimeout => e
239
+ budget_phase_stop(e, subjects)
165
240
  end
166
241
 
167
242
  def run_candidates(subjects, lists, scheduler, cache, ignore_list)
168
243
  @diagnostics.start_progress(lists.sum(&:size))
169
244
  distribution = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
170
245
  cache: cache, on_start: method(:start_worker),
171
- after_subject: canary_check, stop_after: fail_fast_check(ignore_list))
172
- 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) }
173
249
  ensure
174
250
  @diagnostics.finish_progress
175
251
  end
@@ -182,16 +258,15 @@ module Saboteur
182
258
  raise ConfigError, "fail_fast cannot be used with baseline evidence: it certifies the whole selection"
183
259
  end
184
260
 
185
- # Subjects that were selected but never ran, with candidates, are part of
186
- # the report: the verdict covers less than the selection.
187
- def record_early_stop(unreached)
188
- return if unreached.empty?
189
-
190
- @early_stop = EarlyStop.new(reason: "fail_fast", unreached: unreached.map(&:id))
191
- end
192
-
193
261
  # --fail-fast: stop after the first subject that has a survivor the
194
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
+
195
270
  def fail_fast_check(ignore_list)
196
271
  return nil unless config["fail_fast"]
197
272
 
@@ -204,38 +279,40 @@ module Saboteur
204
279
 
205
280
  def inform_about_run(subjects, map)
206
281
  @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
207
- warn_about_threads
282
+ ThreadWarnings.warn(@diagnostics)
208
283
  end
209
284
 
210
- # A --since run without --test runs the loaded suite twice for the
211
- # baselines; a cold coverage cache adds one more pass. Say so
212
- # before those hours pass, with the way to scope it.
213
- def warn_about_unscoped_suite(integration)
214
- 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
215
290
 
216
- message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
217
- "(#{integration.test_files.size} spec files). " \
218
- "Pass --test to run only the specs you name: faster, but a mutant of a changed method is then killed " \
219
- "only by those specs, so specs elsewhere that call this code are not run. " \
220
- "--guess-tests does the same with the specs named after the changed files, " \
221
- "a guess that can miss specs elsewhere."
222
- @diagnostics.warn(message)
223
- @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
224
301
  end
225
302
 
226
- def check_baseline(integration)
227
- BaselineGuard.new(config: config, integration: integration, hooks: hooks,
228
- supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
229
- @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
230
307
  end
231
308
 
232
309
  def scheduler_for(map, integration, cache)
233
310
  reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
234
311
  runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
235
312
  supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"],
236
- sandbox: sandbox_hook)
313
+ sandbox: sandbox_hook, canary: canary)
237
314
  Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
238
- cache: cache)
315
+ cache: cache, deadline: budget_deadline)
239
316
  end
240
317
 
241
318
  # The sandbox hook for each mutant child (#152); the policy and its
@@ -249,7 +326,8 @@ module Saboteur
249
326
  return nil if @replay
250
327
  return nil unless config["incremental"]
251
328
 
252
- inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] })
329
+ inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }
330
+ .merge("operator_policy" => config.operator_policy))
253
331
  ResultCache.new(path: File.join(config.path("cache_dir"), "results.json"), root: @root, inputs: inputs,
254
332
  suite_digest: SuiteFiles.new(config).digest)
255
333
  end
@@ -279,8 +357,17 @@ module Saboteur
279
357
 
280
358
  # Checks the tree after each subject's mutants; a vanished file means a
281
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
+
282
370
  def canary_check
283
- canary = Canary.new(@root, cache_dir: config["cache_dir"])
284
371
  lambda do |subject, results|
285
372
  last = results.reject(&:reused?).last || results.last
286
373
  digest = last&.mutation&.digest(subject_start: subject.start_byte,
@@ -289,6 +376,9 @@ module Saboteur
289
376
  end
290
377
  end
291
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
+
292
382
  def candidate_lists(subjects, scope, map)
293
383
  return [@replay_candidate] if @replay
294
384
 
@@ -297,6 +387,7 @@ module Saboteur
297
387
  limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
298
388
  subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
299
389
  @skipped_subjects = selection.skipped_subjects
390
+ @unscoped_digests = selection.unscoped_digests
300
391
  if @run_evidence
301
392
  @run_evidence.plan(subjects, subjects.map { |subject| selection.selected.fetch(subject) })
302
393
  @evidence_planned = true
@@ -325,19 +416,6 @@ module Saboteur
325
416
  end
326
417
  end
327
418
 
328
- # Threads do not survive fork, so name them: with the name the owner
329
- # gave the thread, else the first frame of its backtrace, so the user
330
- # can tell whether they matter (a connection pool reaper, Sidekiq, an
331
- # SDK's background flusher).
332
- def warn_about_threads
333
- others = Thread.list - [Thread.current]
334
- return if others.empty?
335
-
336
- names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
337
- list = names.join(", ")
338
- @diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
339
- end
340
-
341
419
  # The lines that ran while the app and spec files loaded: the executable
342
420
  # counts from its first line, so this snapshot holds the whole boot.
343
421
  # Read here, in the parent: a forked child does not inherit Coverage
@@ -369,22 +447,22 @@ module Saboteur
369
447
  # Loads the target and stops coverage, returning what the rest of the
370
448
  # run needs from that boot.
371
449
  def boot_target(boot)
450
+ @diagnostics.inform("loading the application")
372
451
  integration = boot.load_target
373
452
  files = config.included_files
374
453
  [integration, files, suspend_coverage(files)]
375
454
  end
376
455
 
377
456
  def suspend_coverage(files)
378
- Coverage.suspend if Coverage.state == :running
457
+ (@coverage_guard = CoverageGuard.new(@diagnostics)).suspend
379
458
  capture_load_lines(files)
380
459
  end
381
460
 
382
- def monotonic
383
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
384
- end
461
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
385
462
  end
386
463
  end
387
464
 
465
+ require_relative "engine/coverage_guard"
388
466
  require_relative "engine/diagnostics"
389
467
  require_relative "engine/boot"
390
468
  require_relative "engine/subject_selection"
@@ -394,6 +472,7 @@ require_relative "engine/test_guess"
394
472
  require_relative "engine/sandbox_policy"
395
473
  require_relative "engine/coverage_store"
396
474
  require_relative "engine/baseline_guard"
475
+ require_relative "engine/unscoped_suite_warning"
397
476
  require_relative "engine/bytecode_equivalence"
398
477
  require_relative "engine/candidates"
399
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