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
@@ -26,6 +26,7 @@ module Saboteur
26
26
  # which refuses instead.
27
27
  def hook
28
28
  return nil unless @config["sandbox"]
29
+ return seatbelt_hook if @config["sandbox"] == "seatbelt" && Execution::Seatbelt.darwin?
29
30
 
30
31
  abi = sandbox_abi
31
32
  return restrict_hook if abi >= 3
@@ -38,8 +39,7 @@ module Saboteur
38
39
  # same line twice (D's review, #178).
39
40
  unless @sandbox_note_given
40
41
  @sandbox_note_given = true
41
- @diagnostics.warn("sandbox is on but #{self.class.sandbox_gap(abi)}; children run with the hazard guard " \
42
- "and canary only")
42
+ @diagnostics.warn(degraded_note(abi))
43
43
  end
44
44
  nil
45
45
  end
@@ -54,16 +54,72 @@ module Saboteur
54
54
  end
55
55
 
56
56
  def self.sandbox_gap(abi)
57
- return "this kernel lacks Landlock (needs Linux 6.2+)" if abi.zero?
57
+ if abi.zero?
58
+ # Landlock is Linux-only: on macOS there is no kernel to upgrade,
59
+ # so name the platform instead of a Linux version (#667).
60
+ return "macOS has no Landlock" if Execution::Seatbelt.darwin?
61
+
62
+ return "this kernel lacks Landlock (needs Linux 6.2+)"
63
+ end
58
64
 
59
65
  "this kernel's Landlock ABI is #{abi} but ABI 3 (Linux 6.2+) is needed to restrict truncate()"
60
66
  end
61
67
 
68
+ # The degraded-run note: on macOS the gap is permanent, so point at
69
+ # the Seatbelt opt-in; on Linux the kernel wording stands alone
70
+ # (#667). Severity stays a warning - degrading confinement is never
71
+ # routine.
72
+ def degraded_note(abi)
73
+ gap = self.class.sandbox_gap(abi)
74
+ tail = "children run with the hazard guard and canary only"
75
+ return "sandbox is on but #{gap}; #{tail}" unless Execution::Seatbelt.darwin?
76
+
77
+ "sandbox is on but #{gap}; #{tail} - opt into the macOS sandbox with c.sandbox = \"seatbelt\" " \
78
+ "(docs/guides/macos-seatbelt-sandbox.md), or set c.sandbox = false to silence this"
79
+ end
80
+
62
81
  def restrict_hook
63
82
  writable = sandbox_writable_dirs
64
83
  ->(keep_fds) { Execution::Sandbox.restrict!(writable: writable, keep_fds: keep_fds) }
65
84
  end
66
85
 
86
+ # c.sandbox = "seatbelt" (the macOS opt-in, #436): the probe decides.
87
+ # Confine when Seatbelt truly enforces; a clean "no Seatbelt here"
88
+ # degrades to the guard and canary with a one-time note; anything
89
+ # else - an errored probe, or an API claiming success its profile
90
+ # cannot prove - refuses the run, exactly like the Landlock probe
91
+ # (#178): opting into a sandbox must never buy silent confinement
92
+ # theatre. Fail-closed lives one layer down too: a child whose
93
+ # profile refuses to apply raises instead of running the mutant.
94
+ def seatbelt_hook
95
+ case (verdict = Execution::Seatbelt.probe)
96
+ when :ok
97
+ writable = sandbox_writable_dirs
98
+ ->(keep_fds) { Execution::Seatbelt.restrict!(writable: writable, keep_fds: keep_fds) }
99
+ when :unavailable
100
+ seatbelt_note("this macOS lacks Seatbelt")
101
+ nil
102
+ else
103
+ raise Error, "sandbox is \"seatbelt\" but the Seatbelt probe #{verdict_description(verdict)}; refusing to " \
104
+ "run unsandboxed - set c.sandbox = true to run with the hazard guard and canary only, " \
105
+ "or false to opt out"
106
+ end
107
+ end
108
+
109
+ def verdict_description(verdict)
110
+ case verdict
111
+ when :unenforced then "reported a profile that did not enforce - a macOS update may have changed it (#436)"
112
+ else "failed (#{verdict}) - #436 tracks the deprecation"
113
+ end
114
+ end
115
+
116
+ def seatbelt_note(reason)
117
+ return if @sandbox_note_given
118
+
119
+ @sandbox_note_given = true
120
+ @diagnostics.warn(%(sandbox is "seatbelt" but #{reason}; children run with the hazard guard and canary only))
121
+ end
122
+
67
123
  # c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
68
124
  # ...): without a grant, writes there fail with EACCES in the child.
69
125
  # Only the user grants are created: the project tmp is granted when it
@@ -12,6 +12,7 @@ module Saboteur
12
12
  # (VerdictCheck). Each mutant runs only the tests that executed its edit,
13
13
  # likeliest killers first, with a timeout from their recorded durations.
14
14
  class Scheduler
15
+ BUDGET_RETRY_REASON = "the --max-duration budget stopped the defined-order retry"
15
16
  WHOLE_FILE_HINT = "; add it to c.whole_file to re-evaluate the whole file"
16
17
 
17
18
  # A subject to run, with what its mutants need. killers counts the
@@ -27,7 +28,11 @@ module Saboteur
27
28
  tests.sort_by.with_index { |id, index| [-killers.fetch(id, 0), index] }
28
29
  end
29
30
 
30
- def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil, deadline: nil)
31
+ # verify_loaded: the shadowed-definition guard resolves each subject's
32
+ # owner in this process. --test-command never boots the app here, so
33
+ # external runs pass false; their neutral check covers a red tree.
34
+ def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil, deadline: nil,
35
+ verify_loaded: true)
31
36
  @config = config
32
37
  @deadline = deadline
33
38
  @runner = runner
@@ -35,6 +40,7 @@ module Saboteur
35
40
  @reevaluator = reevaluator
36
41
  @diagnostics = diagnostics
37
42
  @cache = cache
43
+ @verify_loaded = verify_loaded
38
44
  end
39
45
 
40
46
  def call(subject, candidates)
@@ -49,6 +55,7 @@ module Saboteur
49
55
 
50
56
  tests = @map.tests_for(subject)
51
57
  return unloadable(candidates, subject) if tests.empty?
58
+ return load_time_results(candidates, subject) if load_only?(subject)
52
59
 
53
60
  plan = Plan.new(subject:, tests:, killers: Hash.new(0), slowdown: 1.0, wrapper: wrapper)
54
61
  reused = all_reused(plan, candidates)
@@ -57,7 +64,7 @@ module Saboteur
57
64
  results = run_candidates(candidates) do |mutation, text|
58
65
  reuse(plan, mutation, text) || execute(plan, mutation, text)
59
66
  end
60
- checked(plan, results)
67
+ checked(plan, results, truncated: results.size < candidates.size)
61
68
  end
62
69
 
63
70
  # The per-mutant timeout: timeout_factor times what the tests took
@@ -74,18 +81,25 @@ module Saboteur
74
81
 
75
82
  # Verdicts that did not hold up are warned about once per subject;
76
83
  # only checked verdicts are stored for --incremental.
77
- # A spent budget admits no check: forking the unchanged run with no
78
- # time left would turn genuine kills into fabricated errors and cache
79
- # them. Fresh kills stand unverified instead - outside the score,
80
- # never cached - and the distribution marks the subject so the run
81
- # reports budget-partial.
82
- def checked(plan, results)
83
- return results.map { |result| unverifiable(result) } if budget_spent?
84
-
84
+ # A spent budget admits no check for a subject that finished its
85
+ # mutants: forking the unchanged run with no time left would turn
86
+ # genuine kills into fabricated errors and cache them. Fresh kills
87
+ # stand unverified instead - outside the score, never cached - and
88
+ # the distribution marks the subject so the run reports
89
+ # budget-partial.
90
+ # A subject the budget stopped mid-list is different (#588): its
91
+ # completed mutants were already measured, so their checks still
92
+ # run, uncapped - the budget stops new mutation work, not the
93
+ # confirmation of work already done. The overshoot stays bounded by
94
+ # the subject's distinct killing sequences.
95
+ def checked(plan, results, truncated: false)
96
+ return results.map { |result| unverifiable(result) } if budget_spent? && !truncated
97
+
98
+ @confirming_truncation = truncated
85
99
  subject = plan.subject
86
100
  check = VerdictCheck.new(run_unchanged: unchanged_runner(subject, plan),
87
101
  baseline_timeout: @config["baseline_timeout"])
88
- final = check.call(results)
102
+ final = check.call(results).map { |result| retried_in_defined_order(plan, result) }
89
103
  # Unverified verdicts are never cached: a budget-cut check proves
90
104
  # nothing, and a stored copy would read as confirmed next run.
91
105
  final.reject { |result| result.reused? || result.status == :unverified }.each { |r| @cache&.record(r) }
@@ -94,6 +108,50 @@ module Saboteur
94
108
  @diagnostics.warn("#{subject.id}: #{overturned} verdict(s) failed with the method unchanged")
95
109
  end
96
110
  final
111
+ ensure
112
+ @confirming_truncation = false
113
+ end
114
+
115
+ # A kill rejected because its covering examples fail with the method
116
+ # unchanged may only lack the setup examples the subset run skipped
117
+ # (an order-dependent file, #557). Retry once with the file's examples
118
+ # in defined order, through the last covering example. It stands only
119
+ # if that whole run passes unchanged; the mutant's verdict is then the
120
+ # mutant's run on the same examples. Otherwise the error stays.
121
+ #
122
+ # Cost bound: at most two extra forks per rejected kill, the unchanged
123
+ # prefix run and one mutant run. Ordinarily both spend --max-duration
124
+ # like every other fork: neither starts once the budget is spent, and
125
+ # neither gets more than the time left. A retry the budget stops is
126
+ # unverified, not an error or a kill. The exception is a truncated
127
+ # subject (#588): its completed kills still retry, at the full
128
+ # timeout. Keep this bound if the retry ever grows.
129
+ def retried_in_defined_order(plan, result)
130
+ return result unless result.rejected_kill? && !result.reused? && result.covered_by.any?
131
+
132
+ tests = DefinedOrderPrefix.new(@map.tests).for(result.covered_by)
133
+ return result unless tests
134
+
135
+ unchanged = unchanged_runner(plan.subject, plan).call(tests, @config["baseline_timeout"])
136
+ return result.as_unverified(unchanged.message || BUDGET_RETRY_REASON) if unchanged.status == :unverified
137
+ return result unless unchanged.status == :survived
138
+
139
+ rerun_in_prefix(plan, result, tests)
140
+ end
141
+
142
+ def rerun_in_prefix(plan, result, tests)
143
+ return result.as_unverified(BUDGET_RETRY_REASON) if budget_spent? && !@confirming_truncation
144
+
145
+ full = timeout_for(tests, plan.slowdown)
146
+ capped = @confirming_truncation ? full : budget_capped(full)
147
+ outcome = @runner.run(subject: plan.subject, text: result.mutated_source, test_ids: tests,
148
+ timeout: capped, mutation: result.mutation, eval_prefix: eval_prefix(plan))
149
+ return result.as_unverified(BUDGET_RETRY_REASON) if outcome.status == :timeout && capped < full
150
+
151
+ MutantResult.new(subject: plan.subject, mutation: result.mutation, status: outcome.status,
152
+ mutated_source: result.mutated_source, reason: outcome.message, runtime: outcome.runtime,
153
+ killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause,
154
+ executed: outcome.executed_ids)
97
155
  end
98
156
 
99
157
  # Admission is per sequence, not just at checked() entry: a check
@@ -103,11 +161,11 @@ module Saboteur
103
161
  # the check never got its answer (#296).
104
162
  def unchanged_runner(subject, plan)
105
163
  lambda do |tests, timeout|
106
- if budget_spent?
164
+ if budget_spent? && !@confirming_truncation
107
165
  Execution::Runner::Outcome.new(status: :unverified, runtime: 0.0,
108
166
  message: "the --max-duration budget is spent")
109
167
  else
110
- capped = budget_capped(timeout)
168
+ capped = @confirming_truncation ? timeout : budget_capped(timeout)
111
169
  outcome = @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: capped,
112
170
  eval_prefix: eval_prefix(plan))
113
171
  note_leftovers(subject, "verdict check", outcome)
@@ -191,13 +249,24 @@ module Saboteur
191
249
  [@config["timeout_factor"] * runtime, @config["timeout_min"]].max
192
250
  end
193
251
 
194
- # What a subject no test ran gets. Code that only ran while the app
195
- # loaded is not "no coverage" - it ran - but no forked test can re-run
196
- # it, so it gets its own status, out of the score, with a hint.
252
+ # What a subject no test ran gets. Load-only code is not "no coverage":
253
+ # it ran, but no forked test can re-run it, so it gets its own status.
197
254
  def unloadable(candidates, subject, line_numbers = nil)
255
+ return skip_all(candidates, subject, Selection::CoverageMap::PRELOADED) if @map.preloaded?(subject.file)
198
256
  return skip_all(candidates, subject, "no coverage") unless
199
257
  @map.ran_at_load?(subject.file, line_numbers || subject.coverage_lines)
200
258
 
259
+ load_time_results(candidates, subject)
260
+ end
261
+
262
+ # A def-time macro's only callers ran while the app loaded: the
263
+ # child re-evals the def, but closure consumers captured the
264
+ # pristine definition, so no test can observe a mutant (#409).
265
+ def load_only?(subject)
266
+ !subject.is_a?(ConstantSubject) && @map.called_only_at_load?(subject.file, subject.start_line)
267
+ end
268
+
269
+ def load_time_results(candidates, subject)
201
270
  candidates.map do |mutation, text|
202
271
  MutantResult.new(subject: subject, mutation: mutation, status: :ran_at_load, mutated_source: text,
203
272
  reason: Selection::CoverageMap::RAN_AT_LOAD)
@@ -219,7 +288,50 @@ module Saboteur
219
288
  timeout = timeout_for(tests, plan.slowdown)
220
289
  outcome = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: timeout,
221
290
  mutation: mutation, eval_prefix: eval_prefix(plan))
222
- outcome.status == :timeout ? calibrate(plan, mutation, text, tests, timeout) : outcome
291
+ outcome = calibrate(plan, mutation, text, tests, timeout) if outcome.status == :timeout
292
+ flaky_check(plan, mutation, text, tests, outcome)
293
+ end
294
+
295
+ # --flaky-check (#476): one reordered confirmation run per decisive
296
+ # fresh verdict. A disagreement is unverified - outside the score and
297
+ # never cached - not "rerun until it passes". A reused verdict is
298
+ # already trusted evidence and never reaches here. A budget-cut
299
+ # outcome is already unverified and earns no second fork.
300
+ def flaky_check(plan, mutation, text, tests, outcome)
301
+ return outcome unless flaky_checkable?(outcome)
302
+ return outcome if budget_spent?
303
+
304
+ timeout = timeout_for(tests, plan.slowdown)
305
+
306
+ rerun = @runner.run(subject: plan.subject, text: text, test_ids: reordered(tests),
307
+ timeout: timeout, mutation: mutation, eval_prefix: eval_prefix(plan))
308
+ return outcome if rerun.status == outcome.status
309
+
310
+ Execution::Runner::Outcome.new(status: :unverified, runtime: outcome.runtime + rerun.runtime,
311
+ message: flaky_message(outcome, rerun))
312
+ end
313
+
314
+ # A shuffled order that is guaranteed to differ (a two-test shuffle
315
+ # has even odds of coming out identical): order-dependence is exactly
316
+ # what the confirmation run hunts.
317
+ def reordered(tests)
318
+ shuffled = tests.shuffle(random: Random.new(@config["seed"]))
319
+ shuffled == tests && shuffled.size > 1 ? shuffled.rotate : shuffled
320
+ end
321
+
322
+ def flaky_checkable?(outcome)
323
+ case @config["flaky_check"]
324
+ when "survivors" then outcome.status == :survived
325
+ when "decisive" then %i[survived killed].include?(outcome.status)
326
+ else false
327
+ end
328
+ end
329
+
330
+ def flaky_message(outcome, rerun)
331
+ decisive = (outcome.failed_ids | rerun.failed_ids).first(3)
332
+ named = decisive.empty? ? "" : " (decisive tests: #{decisive.join(', ')})"
333
+ "flaky verdict: first #{outcome.status}, then #{rerun.status} on a reordered confirmation " \
334
+ "run#{named} - the mutant is unverified, not #{outcome.status}"
223
335
  end
224
336
 
225
337
  # Each mutant is checked separately: changing a body can change which
@@ -329,6 +441,8 @@ module Saboteur
329
441
  # sig is one, see Execution::Wrapper) is not a shadowing - the child
330
442
  # re-evaluates it together with the def, so the check passes.
331
443
  def load_problem(subject)
444
+ return [nil, nil] unless @verify_loaded
445
+
332
446
  return [nil, nil] if @reevaluator.whole_file?(subject)
333
447
 
334
448
  owner = @reevaluator.resolve_owner(subject)
@@ -0,0 +1,59 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Nothing to mutate is the run's outcome, already stated by the
7
+ # "0 subjects, 0 mutants" summary: note it once (#468). As a report
8
+ # warning it would print again at the end of the run, and a zero-subject
9
+ # run ends right away, so the repeat reads like a second failure.
10
+ module SelectionAnnouncement
11
+ private
12
+
13
+ # A --since diff with no changed source file (a spec-only or docs-only
14
+ # change) yields zero subjects, so the suite never runs: say there is
15
+ # nothing to test rather than promise a whole-suite run that does not
16
+ # happen (#607).
17
+ def selection_message(guess)
18
+ if guess.changed.zero? && config["since"]
19
+ "inferred specs: no changed source file to infer a spec for, so there is nothing to test"
20
+ elsif guess.changed.zero?
21
+ "inferred specs: no source file to infer a spec for, so the whole suite runs"
22
+ elsif guess.specs.empty?
23
+ guess.no_specs_message(since: config["since"])
24
+ else
25
+ scope_to_guesses(guess)
26
+ end
27
+ end
28
+
29
+ def announce_selection(guess)
30
+ if guess.changed.zero? || guess.specs.any?
31
+ @diagnostics.announce(selection_message(guess))
32
+ else
33
+ @diagnostics.inform(selection_message(guess))
34
+ end
35
+ end
36
+
37
+ # A spec file the diff touched that cannot run is named, also before a
38
+ # stop on an unmatched file: --test, the way out, never reads the diff.
39
+ def inform_skipped_specs(guess) = (@diagnostics.inform(guess.skipped_message) if guess.skipped_specs.any?)
40
+
41
+ # Stop before boot: name the spec-less files and the ways out. The
42
+ # hinted spec paths are the conventional ones - they do not exist (that
43
+ # is why the files are unmatched), so the message never prints them as
44
+ # a runnable --test command, and it never reconstructs the source
45
+ # scope: the user appends --test or --all to their own command. The
46
+ # example is still shell-safe: the ref is user input, so it is quoted
47
+ # and pinned to the flag with = (a ref like "-x" must not become a
48
+ # switch).
49
+ def scope_to_guesses(guess)
50
+ # The guesses now stand as explicit tests, like --test. guess_tests is
51
+ # dropped rather than set to false: an explicit false is the deprecated
52
+ # opt-out and would warn.
53
+ @config = config.with({ "tests" => guess.specs }, except: ["guess_tests"])
54
+ @guessed_tests = guess.specs
55
+ guess.scope_message(since: config["since"])
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+
6
+ module Saboteur
7
+ class Engine
8
+ # Deterministic 1-based k/n split of the planned candidate inventory.
9
+ # The key names the candidate, never its position, so shards stay stable
10
+ # when an earlier subject gains or loses a mutant.
11
+ class Shard
12
+ FORMAT = %r{\A(\d+)/(\d+)\z}
13
+ INVENTORY_JOINER = "\0"
14
+
15
+ attr_reader :index, :total, :all_keys, :selected_keys, :inventory_digest
16
+
17
+ def self.parse(value)
18
+ match = FORMAT.match(value.to_s)
19
+ index, total = match.captures.map(&:to_i) if match
20
+ return new(index, total) if match && index.positive? && total >= 2 && index <= total
21
+
22
+ raise ConfigError, "shard must be k/n with 1 <= k <= n and n >= 2 (for example 1/4)"
23
+ end
24
+
25
+ def initialize(index, total)
26
+ @index = index
27
+ @total = total
28
+ end
29
+
30
+ # Returns lists in the same shape as candidate_lists: [[mutation, text]] per subject.
31
+ def select(subjects, lists)
32
+ keyed = subjects.zip(lists).map do |subject, list|
33
+ keys = list.map { |mutation, _| key(subject, mutation) }
34
+ [keys, list]
35
+ end
36
+ @all_keys = keyed.flat_map(&:first)
37
+ @inventory_digest = Digest::SHA256.hexdigest(@all_keys.sort.join(INVENTORY_JOINER))
38
+ @selected_keys = @all_keys.select { |key| shard_for(key) == index - 1 }
39
+ selected = @selected_keys.to_set
40
+ keyed.map do |keys, list|
41
+ list.select.with_index { |_, position| selected.include?(keys[position]) }
42
+ end
43
+ end
44
+
45
+ def planned_count = all_keys.size
46
+ def selected_count = selected_keys.size
47
+
48
+ def line
49
+ "Shard #{index}/#{total}: #{selected_count} of #{planned_count} planned mutants; " \
50
+ "inventory sha256:#{inventory_digest}; partial run"
51
+ end
52
+
53
+ def to_h
54
+ { index: index, of: total, selected: selected_count, planned: planned_count,
55
+ inventoryDigest: inventory_digest, partial: true }
56
+ end
57
+
58
+ private
59
+
60
+ def key(subject, mutation)
61
+ "#{subject.id} #{mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)}"
62
+ end
63
+
64
+ def shard_for(key)
65
+ Digest::SHA256.digest(key).unpack1("Q>") % total
66
+ end
67
+ end
68
+ end
69
+ end