saboteur 0.7.0 → 0.8.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +657 -0
  3. data/README.md +198 -1077
  4. data/lib/saboteur/cli/exit_status.rb +32 -1
  5. data/lib/saboteur/cli/help_options.rb +2 -1
  6. data/lib/saboteur/cli/history_command.rb +135 -0
  7. data/lib/saboteur/cli/presets.rb +6 -2
  8. data/lib/saboteur/cli/selection_options.rb +31 -0
  9. data/lib/saboteur/cli/shard_options.rb +17 -0
  10. data/lib/saboteur/cli/source_set.rb +53 -0
  11. data/lib/saboteur/cli/survivors_command.rb +157 -0
  12. data/lib/saboteur/cli.rb +71 -54
  13. data/lib/saboteur/config.rb +98 -5
  14. data/lib/saboteur/config_file.rb +8 -0
  15. data/lib/saboteur/constant_reads.rb +182 -0
  16. data/lib/saboteur/constant_subject.rb +70 -0
  17. data/lib/saboteur/constant_subject_finder.rb +281 -0
  18. data/lib/saboteur/coverage_reads.rb +29 -0
  19. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  20. data/lib/saboteur/doctor.rb +9 -0
  21. data/lib/saboteur/engine/baseline_guard.rb +62 -14
  22. data/lib/saboteur/engine/boot.rb +4 -0
  23. data/lib/saboteur/engine/candidate_selection.rb +19 -0
  24. data/lib/saboteur/engine/candidates.rb +43 -3
  25. data/lib/saboteur/engine/coverage_store.rb +32 -10
  26. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  27. data/lib/saboteur/engine/diagnostics.rb +18 -0
  28. data/lib/saboteur/engine/distribution.rb +10 -0
  29. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  30. data/lib/saboteur/engine/gated_run.rb +35 -0
  31. data/lib/saboteur/engine/load_coverage.rb +45 -0
  32. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  33. data/lib/saboteur/engine/pipelines.rb +107 -0
  34. data/lib/saboteur/engine/result_cache.rb +65 -8
  35. data/lib/saboteur/engine/run_budget.rb +27 -0
  36. data/lib/saboteur/engine/sandbox_policy.rb +38 -0
  37. data/lib/saboteur/engine/scheduler.rb +109 -3
  38. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  39. data/lib/saboteur/engine/shard.rb +69 -0
  40. data/lib/saboteur/engine/subject_selection.rb +295 -20
  41. data/lib/saboteur/engine/test_guess.rb +315 -25
  42. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  43. data/lib/saboteur/engine.rb +100 -127
  44. data/lib/saboteur/execution/external_runner.rb +471 -0
  45. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  46. data/lib/saboteur/execution/peak_memory.rb +28 -0
  47. data/lib/saboteur/execution/process_table.rb +12 -0
  48. data/lib/saboteur/execution/process_tree.rb +65 -5
  49. data/lib/saboteur/execution/reevaluator.rb +68 -12
  50. data/lib/saboteur/execution/runner.rb +46 -7
  51. data/lib/saboteur/execution/sandbox.rb +15 -2
  52. data/lib/saboteur/execution/seatbelt.rb +226 -0
  53. data/lib/saboteur/execution/supervisor.rb +27 -10
  54. data/lib/saboteur/execution/worker_pool.rb +23 -1
  55. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  56. data/lib/saboteur/gates.rb +43 -2
  57. data/lib/saboteur/help.rb +60 -9
  58. data/lib/saboteur/ignore_list.rb +29 -6
  59. data/lib/saboteur/integration/rspec.rb +135 -5
  60. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  61. data/lib/saboteur/mutation_source/builtin.rb +26 -5
  62. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  63. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  64. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  65. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  67. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  68. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  69. data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
  70. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  71. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  72. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  73. data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
  74. data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
  75. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  76. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  77. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  79. data/lib/saboteur/preview.rb +120 -12
  80. data/lib/saboteur/refinement_activations.rb +60 -0
  81. data/lib/saboteur/reporter/agent.rb +9 -1
  82. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  83. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  84. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  85. data/lib/saboteur/reporter/findings.rb +5 -2
  86. data/lib/saboteur/reporter/history_policy.rb +11 -0
  87. data/lib/saboteur/reporter/history_reader.rb +47 -0
  88. data/lib/saboteur/reporter/html.rb +26 -4
  89. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  90. data/lib/saboteur/reporter/stryker.rb +27 -3
  91. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  92. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  93. data/lib/saboteur/reporter/terminal.rb +40 -7
  94. data/lib/saboteur/result.rb +19 -3
  95. data/lib/saboteur/selection/coverage_map.rb +105 -10
  96. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  97. data/lib/saboteur/selection/external_test_map.rb +27 -0
  98. data/lib/saboteur/selection/git_diff.rb +93 -11
  99. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  100. data/lib/saboteur/selection/recorder.rb +22 -4
  101. data/lib/saboteur/selection/source_scope.rb +22 -0
  102. data/lib/saboteur/subject.rb +42 -7
  103. data/lib/saboteur/subject_finder.rb +173 -29
  104. data/lib/saboteur/version.rb +1 -1
  105. data/lib/saboteur/working_tree.rb +38 -15
  106. data/lib/saboteur.rb +13 -0
  107. metadata +35 -2
@@ -41,6 +41,33 @@ module Saboteur
41
41
  config["max_duration"] && (@run_started + config["max_duration"])
42
42
  end
43
43
 
44
+ # Once the baselines are timed, a cold coverage build is the last
45
+ # unpriced phase before the mutants: it costs about one more full pass
46
+ # and keeps nothing when the budget cuts it off, so a resume starts it
47
+ # from zero again. Refuse up front when the remaining budget cannot
48
+ # cover that pass, naming the shortfall and the remedies (#427).
49
+ def refuse_unaffordable_coverage_build(store, files, baseline_elapsed = @baseline_elapsed,
50
+ completed_passes = @completed_baseline_passes)
51
+ return unless config["max_duration"]
52
+ return if store.reusable?(files)
53
+ return if completed_passes.to_i.zero?
54
+
55
+ per_pass = baseline_elapsed / completed_passes
56
+ remaining = config["max_duration"] - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started)
57
+ # A spent budget is the existing stop path's news, and an untimed
58
+ # (zero-second) baseline carries no estimate to project from.
59
+ return if remaining <= 0 || per_pass <= 0
60
+ return if remaining >= per_pass
61
+
62
+ needed = per_pass - remaining
63
+ raise BudgetPhaseTimeout,
64
+ "the --max-duration budget cannot cover the coverage map build: baseline passes took about " \
65
+ "#{per_pass.round}s each, only #{remaining.round(1)}s remain, and a cold build needs about one " \
66
+ "more full pass and keeps nothing when it is cut off. Re-run with --max-duration about " \
67
+ "#{needed.round}s higher, fewer --baseline-passes, or once without --max-duration so the " \
68
+ "coverage cache is built for later --incremental runs"
69
+ end
70
+
44
71
  # Boot and baselines can spend the budget by themselves: stop before
45
72
  # an expensive cold coverage build and mark every selected subject
46
73
  # unrun.
@@ -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
@@ -64,6 +65,43 @@ module Saboteur
64
65
  ->(keep_fds) { Execution::Sandbox.restrict!(writable: writable, keep_fds: keep_fds) }
65
66
  end
66
67
 
68
+ # c.sandbox = "seatbelt" (the macOS opt-in, #436): the probe decides.
69
+ # Confine when Seatbelt truly enforces; a clean "no Seatbelt here"
70
+ # degrades to the guard and canary with a one-time note; anything
71
+ # else - an errored probe, or an API claiming success its profile
72
+ # cannot prove - refuses the run, exactly like the Landlock probe
73
+ # (#178): opting into a sandbox must never buy silent confinement
74
+ # theatre. Fail-closed lives one layer down too: a child whose
75
+ # profile refuses to apply raises instead of running the mutant.
76
+ def seatbelt_hook
77
+ case (verdict = Execution::Seatbelt.probe)
78
+ when :ok
79
+ writable = sandbox_writable_dirs
80
+ ->(keep_fds) { Execution::Seatbelt.restrict!(writable: writable, keep_fds: keep_fds) }
81
+ when :unavailable
82
+ seatbelt_note("this macOS lacks Seatbelt")
83
+ nil
84
+ else
85
+ raise Error, "sandbox is \"seatbelt\" but the Seatbelt probe #{verdict_description(verdict)}; refusing to " \
86
+ "run unsandboxed - set c.sandbox = true to run with the hazard guard and canary only, " \
87
+ "or false to opt out"
88
+ end
89
+ end
90
+
91
+ def verdict_description(verdict)
92
+ case verdict
93
+ when :unenforced then "reported a profile that did not enforce - a macOS update may have changed it (#436)"
94
+ else "failed (#{verdict}) - #436 tracks the deprecation"
95
+ end
96
+ end
97
+
98
+ def seatbelt_note(reason)
99
+ return if @sandbox_note_given
100
+
101
+ @sandbox_note_given = true
102
+ @diagnostics.warn(%(sandbox is "seatbelt" but #{reason}; children run with the hazard guard and canary only))
103
+ end
104
+
67
105
  # c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
68
106
  # ...): without a grant, writes there fail with EACCES in the child.
69
107
  # 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)
@@ -85,7 +92,7 @@ module Saboteur
85
92
  subject = plan.subject
86
93
  check = VerdictCheck.new(run_unchanged: unchanged_runner(subject, plan),
87
94
  baseline_timeout: @config["baseline_timeout"])
88
- final = check.call(results)
95
+ final = check.call(results).map { |result| retried_in_defined_order(plan, result) }
89
96
  # Unverified verdicts are never cached: a budget-cut check proves
90
97
  # nothing, and a stored copy would read as confirmed next run.
91
98
  final.reject { |result| result.reused? || result.status == :unverified }.each { |r| @cache&.record(r) }
@@ -96,6 +103,46 @@ module Saboteur
96
103
  final
97
104
  end
98
105
 
106
+ # A kill rejected because its covering examples fail with the method
107
+ # unchanged may only lack the setup examples the subset run skipped
108
+ # (an order-dependent file, #557). Retry once with the file's examples
109
+ # in defined order, through the last covering example. It stands only
110
+ # if that whole run passes unchanged; the mutant's verdict is then the
111
+ # mutant's run on the same examples. Otherwise the error stays.
112
+ #
113
+ # Cost bound: at most two extra forks per rejected kill, the unchanged
114
+ # prefix run and one mutant run. Both spend --max-duration like every
115
+ # other fork: neither starts once the budget is spent, and neither gets
116
+ # more than the time left. A retry the budget stops is unverified, not
117
+ # an error or a kill. Keep this bound if the retry ever grows.
118
+ def retried_in_defined_order(plan, result)
119
+ return result unless result.rejected_kill? && !result.reused? && result.covered_by.any?
120
+
121
+ tests = DefinedOrderPrefix.new(@map.tests).for(result.covered_by)
122
+ return result unless tests
123
+
124
+ unchanged = unchanged_runner(plan.subject, plan).call(tests, @config["baseline_timeout"])
125
+ return result.as_unverified(unchanged.message || BUDGET_RETRY_REASON) if unchanged.status == :unverified
126
+ return result unless unchanged.status == :survived
127
+
128
+ rerun_in_prefix(plan, result, tests)
129
+ end
130
+
131
+ def rerun_in_prefix(plan, result, tests)
132
+ return result.as_unverified(BUDGET_RETRY_REASON) if budget_spent?
133
+
134
+ full = timeout_for(tests, plan.slowdown)
135
+ capped = budget_capped(full)
136
+ outcome = @runner.run(subject: plan.subject, text: result.mutated_source, test_ids: tests,
137
+ timeout: capped, mutation: result.mutation, eval_prefix: eval_prefix(plan))
138
+ return result.as_unverified(BUDGET_RETRY_REASON) if outcome.status == :timeout && capped < full
139
+
140
+ MutantResult.new(subject: plan.subject, mutation: result.mutation, status: outcome.status,
141
+ mutated_source: result.mutated_source, reason: outcome.message, runtime: outcome.runtime,
142
+ killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause,
143
+ executed: outcome.executed_ids)
144
+ end
145
+
99
146
  # Admission is per sequence, not just at checked() entry: a check
100
147
  # admitted with time left can hit its capped timeout mid-check, and a
101
148
  # later sequence can arrive after the budget is spent. Neither may
@@ -204,6 +251,20 @@ module Saboteur
204
251
  end
205
252
  end
206
253
 
254
+ # A def-time macro's only callers ran while the app loaded: the
255
+ # child re-evals the def, but closure consumers captured the
256
+ # pristine definition, so no test can observe a mutant (#409).
257
+ def load_only?(subject)
258
+ !subject.is_a?(ConstantSubject) && @map.called_only_at_load?(subject.file, subject.start_line)
259
+ end
260
+
261
+ def load_time_results(candidates, subject)
262
+ candidates.map do |mutation, text|
263
+ MutantResult.new(subject: subject, mutation: mutation, status: :ran_at_load, mutated_source: text,
264
+ reason: Selection::CoverageMap::RAN_AT_LOAD)
265
+ end
266
+ end
267
+
207
268
  # One mutation whose edited lines no test ran (see unloadable).
208
269
  def uncoverable(plan, mutation, text)
209
270
  line_numbers = mutation.edits.flat_map { |edit| plan.subject.coverage_lines_for(edit) }.uniq
@@ -219,7 +280,50 @@ module Saboteur
219
280
  timeout = timeout_for(tests, plan.slowdown)
220
281
  outcome = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: timeout,
221
282
  mutation: mutation, eval_prefix: eval_prefix(plan))
222
- outcome.status == :timeout ? calibrate(plan, mutation, text, tests, timeout) : outcome
283
+ outcome = calibrate(plan, mutation, text, tests, timeout) if outcome.status == :timeout
284
+ flaky_check(plan, mutation, text, tests, outcome)
285
+ end
286
+
287
+ # --flaky-check (#476): one reordered confirmation run per decisive
288
+ # fresh verdict. A disagreement is unverified - outside the score and
289
+ # never cached - not "rerun until it passes". A reused verdict is
290
+ # already trusted evidence and never reaches here. A budget-cut
291
+ # outcome is already unverified and earns no second fork.
292
+ def flaky_check(plan, mutation, text, tests, outcome)
293
+ return outcome unless flaky_checkable?(outcome)
294
+ return outcome if budget_spent?
295
+
296
+ timeout = timeout_for(tests, plan.slowdown)
297
+
298
+ rerun = @runner.run(subject: plan.subject, text: text, test_ids: reordered(tests),
299
+ timeout: timeout, mutation: mutation, eval_prefix: eval_prefix(plan))
300
+ return outcome if rerun.status == outcome.status
301
+
302
+ Execution::Runner::Outcome.new(status: :unverified, runtime: outcome.runtime + rerun.runtime,
303
+ message: flaky_message(outcome, rerun))
304
+ end
305
+
306
+ # A shuffled order that is guaranteed to differ (a two-test shuffle
307
+ # has even odds of coming out identical): order-dependence is exactly
308
+ # what the confirmation run hunts.
309
+ def reordered(tests)
310
+ shuffled = tests.shuffle(random: Random.new(@config["seed"]))
311
+ shuffled == tests && shuffled.size > 1 ? shuffled.rotate : shuffled
312
+ end
313
+
314
+ def flaky_checkable?(outcome)
315
+ case @config["flaky_check"]
316
+ when "survivors" then outcome.status == :survived
317
+ when "decisive" then %i[survived killed].include?(outcome.status)
318
+ else false
319
+ end
320
+ end
321
+
322
+ def flaky_message(outcome, rerun)
323
+ decisive = (outcome.failed_ids | rerun.failed_ids).first(3)
324
+ named = decisive.empty? ? "" : " (decisive tests: #{decisive.join(', ')})"
325
+ "flaky verdict: first #{outcome.status}, then #{rerun.status} on a reordered confirmation " \
326
+ "run#{named} - the mutant is unverified, not #{outcome.status}"
223
327
  end
224
328
 
225
329
  # Each mutant is checked separately: changing a body can change which
@@ -329,6 +433,8 @@ module Saboteur
329
433
  # sig is one, see Execution::Wrapper) is not a shadowing - the child
330
434
  # re-evaluates it together with the def, so the check passes.
331
435
  def load_problem(subject)
436
+ return [nil, nil] unless @verify_loaded
437
+
332
438
  return [nil, nil] if @reevaluator.whole_file?(subject)
333
439
 
334
440
  owner = @reevaluator.resolve_owner(subject)
@@ -0,0 +1,54 @@
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
+ def selection_message(guess)
14
+ if guess.changed.zero?
15
+ target = config["since"] ? "no changed source file" : "no source file"
16
+ "inferred specs: #{target} to infer a spec for, so the whole suite runs"
17
+ elsif guess.specs.empty?
18
+ guess.no_specs_message(since: config["since"])
19
+ else
20
+ scope_to_guesses(guess)
21
+ end
22
+ end
23
+
24
+ def announce_selection(guess)
25
+ if guess.changed.zero? || guess.specs.any?
26
+ @diagnostics.announce(selection_message(guess))
27
+ else
28
+ @diagnostics.inform(selection_message(guess))
29
+ end
30
+ end
31
+
32
+ # A spec file the diff touched that cannot run is named, also before a
33
+ # stop on an unmatched file: --test, the way out, never reads the diff.
34
+ def inform_skipped_specs(guess) = (@diagnostics.inform(guess.skipped_message) if guess.skipped_specs.any?)
35
+
36
+ # Stop before boot: name the spec-less files and the ways out. The
37
+ # hinted spec paths are the conventional ones - they do not exist (that
38
+ # is why the files are unmatched), so the message never prints them as
39
+ # a runnable --test command, and it never reconstructs the source
40
+ # scope: the user appends --test or --all to their own command. The
41
+ # example is still shell-safe: the ref is user input, so it is quoted
42
+ # and pinned to the flag with = (a ref like "-x" must not become a
43
+ # switch).
44
+ def scope_to_guesses(guess)
45
+ # The guesses now stand as explicit tests, like --test. guess_tests is
46
+ # dropped rather than set to false: an explicit false is the deprecated
47
+ # opt-out and would warn.
48
+ @config = config.with({ "tests" => guess.specs }, except: ["guess_tests"])
49
+ @guessed_tests = guess.specs
50
+ guess.scope_message(since: config["since"])
51
+ end
52
+ end
53
+ end
54
+ 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