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
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "securerandom"
5
+
4
6
  require_relative "process_tree"
5
7
  require_relative "protocol"
6
8
 
@@ -21,13 +23,23 @@ module Saboteur
21
23
  # 4. Refusing recursion: the child is marked with CHILD_ENV, and Engine
22
24
  # refuses to start a run inside a marked process.
23
25
  # 5. On Linux, the supervisor is a child subreaper, so escaped orphans are
24
- # re-parented to it instead of init and stay in its tree.
26
+ # re-parented to it instead of init and stay reaped by it.
27
+ # 6. Every child carries a unique marker in its environment; the tree
28
+ # poll can miss a descendant that leaves the group and loses its
29
+ # intermediate parent between polls (#365), so kill_all also sweeps
30
+ # the process table for the marker, which survives both escapes.
25
31
  class Supervisor
26
32
  # Set in every child to its nesting depth (1 for a child of a top-level
27
33
  # run). Engine refuses to start when it is set; the supervisor itself
28
34
  # refuses to fork past MAX_DEPTH, so even a run with that guard removed
29
35
  # (a mutant of the guard, say) cannot recurse without bound.
30
36
  CHILD_ENV = "SABOTEUR_CHILD"
37
+ # Colon-joined marker list in a child's environment: the run's own
38
+ # token last, its ancestors' before it, so any ancestor's sweep still
39
+ # names a leaked nested run. See ProcessTree's marker sweep.
40
+ TREE_ENV = "SABOTEUR_TREE"
41
+ # The comm prefix of a supervised child; comm holds 15 bytes.
42
+ COMM_PREFIX = "sbt"
31
43
  MAX_DEPTH = 4
32
44
  # The hard CPU limit, as a multiple of the budget (see limit_cpu).
33
45
  HARD_CPU_FACTOR = 20
@@ -47,8 +59,8 @@ module Saboteur
47
59
  # least half of the memory limit, the evidence that a SIGKILL may be
48
60
  # an out-of-memory kill.
49
61
  Result = Data.define(:payload, :exit_status, :stopped_by, :detail, :runtime, :leftover_pids,
50
- :supervisor_killed, :memory_pressure) do
51
- def initialize(supervisor_killed: false, memory_pressure: false, **fields)
62
+ :leftover_notes, :supervisor_killed, :memory_pressure) do
63
+ def initialize(leftover_notes: [], supervisor_killed: false, memory_pressure: false, **fields)
52
64
  super
53
65
  end
54
66
 
@@ -132,6 +144,9 @@ module Saboteur
132
144
  @peak_growth_kb = 0
133
145
  supervisor = Process.pid
134
146
  @child_label = label
147
+ # Read by the forked child through its copy of this process, like
148
+ # @child_label above.
149
+ @marker = SecureRandom.hex(6)
135
150
  pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
136
151
  writer.close
137
152
  contain(pid, reader, started, timeout, on_tick)
@@ -147,6 +162,8 @@ module Saboteur
147
162
  reader.close
148
163
  self.class.close_in_child.each { |io| io.close unless io.closed? }
149
164
  ENV[CHILD_ENV] = depth.to_s
165
+ ENV[TREE_ENV] = [ENV.fetch(TREE_ENV, nil), @marker].compact.join(":")
166
+ MarkerSweep.write_comm(@marker)
150
167
  # Best-effort like the parent-side own_group: macOS can refuse the
151
168
  # group change (EPERM), and the child must still run (#296).
152
169
  own_group(0)
@@ -210,7 +227,7 @@ module Saboteur
210
227
  # --- parent ---------------------------------------------------------
211
228
 
212
229
  def contain(pid, reader, started, timeout, on_tick)
213
- tree = ProcessTree.new(pid)
230
+ tree = ProcessTree.new(pid, marker: @marker)
214
231
  self.class.live[pid] = tree
215
232
  own_group(pid)
216
233
  deadline = started + timeout
@@ -226,27 +243,27 @@ module Saboteur
226
243
  # (progress IO failed) or any other error must not skip the
227
244
  # kill/reap, or the child and its tree would run unsupervised
228
245
  # until this process exits.
229
- exit_status, supervisor_killed, leftover_pids = release(pid, tree, exit_status, stop, grace_expired)
246
+ exit_status, supervisor_killed, leftover_pids, notes = release(pid, tree, exit_status, stop, grace_expired)
230
247
  end
231
248
  Result.new(payload: payload, exit_status: exit_status, stopped_by: stop, detail: detail,
232
- runtime: monotonic - started, leftover_pids: leftover_pids,
249
+ runtime: monotonic - started, leftover_pids: leftover_pids, leftover_notes: notes,
233
250
  supervisor_killed: supervisor_killed, memory_pressure: memory_pressure?)
234
251
  end
235
252
 
236
- # Kills and reaps the child and everything it started. Returns
237
- # [exit_status, supervisor_killed, leftover_pids].
253
+ # Kills and reaps the child and its tree; returns [exit_status, supervisor_killed, leftover_pids, notes].
238
254
  def release(pid, tree, exit_status, stop, grace_expired)
239
255
  exit_status ||= terminate(pid) if stop == :timeout
240
256
  supervisor_killed = false
241
257
  # The grace ran out with the child seemingly alive. What ends it is
242
258
  # only ours to claim if we see it alive: stop it first.
243
259
  exit_status, supervisor_killed = freeze_child(pid) if grace_expired
244
- leftover_pids = tree.kill_all - [pid]
260
+ # Notes name the leftovers before the kill erases their command lines (#486).
261
+ leftover_pids, notes = tree.kill_all_except(pid)
245
262
  exit_status ||= reap(pid)
246
263
  self.class.live.delete(pid)
247
264
  reap_adopted(leftover_pids)
248
265
  reap_group(pid)
249
- [exit_status, supervisor_killed, leftover_pids]
266
+ [exit_status, supervisor_killed, leftover_pids, notes]
250
267
  end
251
268
 
252
269
  # After EOF, waits for the child to exit by itself while still checking
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "json"
5
5
  require_relative "../text"
6
+ require_relative "peak_memory"
6
7
  require_relative "process_tree"
7
8
  require_relative "supervisor"
8
9
 
@@ -26,12 +27,17 @@ module Saboteur
26
27
  class WorkerPool
27
28
  # done: items finished; status: the Process::Status once reaped;
28
29
  # last_words: what the worker reported before dying, if anything.
29
- Worker = Struct.new(:number, :pid, :commands, :results, :current, :done, :status, :last_words)
30
+ Worker = Struct.new(:number, :pid, :commands, :results, :current, :done, :status, :last_words, :peak_rss_mb)
30
31
  START_FAILURES = 3
31
32
 
32
33
  # on_start: called in each worker with its number before its first
33
34
  # item (per-worker database connections, for example).
35
+ # worker_peaks: worker number => peak RSS in MB, filled as workers
36
+ # retire (#202). Empty for an inline (jobs 1) run.
37
+ attr_reader :worker_peaks
38
+
34
39
  def initialize(jobs:, on_start: nil, on_result: nil, drain_timeout: 30)
40
+ @worker_peaks = {}
35
41
  @jobs = jobs
36
42
  @on_start = on_start
37
43
  @on_result = on_result
@@ -154,6 +160,10 @@ module Saboteur
154
160
  # Stores a line from the worker: its item's result, or why it crashed.
155
161
  def take(worker, line, results)
156
162
  data = parse(line)
163
+ if data.key?("peak_rss_mb")
164
+ @worker_peaks[data.delete("worker") || worker.number] = worker.peak_rss_mb = data["peak_rss_mb"]
165
+ return
166
+ end
157
167
  return worker.last_words = data["fatal"] if data.key?("fatal")
158
168
  return unless worker.current
159
169
 
@@ -285,6 +295,7 @@ module Saboteur
285
295
  results.puts(JSON.generate(run_one(items[Integer(line)], &)))
286
296
  results.flush
287
297
  end
298
+ report_peak(results, number)
288
299
  exit!(0)
289
300
  rescue Exception => e # rubocop:disable Lint/RescueException
290
301
  last_words(results, "worker #{number} crashed: #{e.class}: #{e.message} (#{e.backtrace&.first})")
@@ -298,6 +309,16 @@ module Saboteur
298
309
  nil
299
310
  end
300
311
 
312
+ # Last word before exit!: the kernel's exact peak RSS of this worker,
313
+ # for the run's memory report (#202). Skipped where procfs is absent.
314
+ def report_peak(results, number)
315
+ peak = PeakMemory.peak_mb
316
+ return unless peak
317
+
318
+ results.puts(JSON.generate("worker" => number, "peak_rss_mb" => peak))
319
+ results.flush
320
+ end
321
+
301
322
  def run_one(item, &work)
302
323
  work.call(item)
303
324
  rescue Error => e
@@ -319,6 +340,7 @@ module Saboteur
319
340
  sleep 0.01 until reaped?(worker) || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
320
341
  tree&.kill_all
321
342
  worker.status ||= Process.wait2(worker.pid).last unless reaped?(worker)
343
+ drain(worker, {})
322
344
  worker.results.close unless worker.results.closed?
323
345
  rescue Errno::ECHILD
324
346
  nil
@@ -0,0 +1,49 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Frameworks
6
+ # The #387 shape: a suite connects ActiveRecord to a sqlite ":memory:"
7
+ # database and loads its schema at require time. Every saboteur child is
8
+ # a fork, ActiveRecord's fork-safe pool reconnects there, and a fresh
9
+ # in-memory database is EMPTY - the schema is gone, and every example
10
+ # fails with "Could not find table" while plain rspec stays green. A
11
+ # child that detects the shape says so and names the remedies instead of
12
+ # sending the user debugging a healthy suite.
13
+ module SqliteMemoryDatabase
14
+ REMEDY = "a sqlite :memory: database is empty in this forked child: in-memory databases do not " \
15
+ "survive saboteur's fork, so a schema loaded at require time is lost. Load the schema in " \
16
+ "a before(:suite) hook, or in an after_fork hook (config/saboteur_hooks.rb via c.hooks)"
17
+
18
+ module_function
19
+
20
+ # The remedy text when a current pool points at an empty sqlite
21
+ # :memory: database, else nil. Diagnostic only: any error is no hint,
22
+ # never a failure of the run itself.
23
+ def fork_loss_hint
24
+ return unless defined?(ActiveRecord::Base)
25
+
26
+ pools = ActiveRecord::Base.connection_handler.connection_pool_list
27
+ pools.any? { |pool| empty_memory_database?(pool) } ? REMEDY : nil
28
+ rescue StandardError, ScriptError
29
+ nil
30
+ end
31
+
32
+ def empty_memory_database?(pool)
33
+ config = pool.db_config
34
+ return false unless config.adapter.to_s == "sqlite3" && config.database.to_s == ":memory:"
35
+
36
+ pool.with_connection { |connection| user_tables(connection).empty? }
37
+ rescue StandardError
38
+ false
39
+ end
40
+
41
+ # Migration bookkeeping is not user schema: a database with only these
42
+ # tables still has no schema for the suite's models.
43
+ def user_tables(connection)
44
+ connection.select_values("SELECT name FROM sqlite_master WHERE type = 'table'") -
45
+ %w[sqlite_sequence schema_migrations ar_internal_metadata]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -41,12 +41,18 @@ module Saboteur
41
41
 
42
42
  def initialize(config)
43
43
  @limits = SETTINGS.keys.to_h { |key| [key, self.class.limit(config[key], key)] }
44
+ @score_gates = Array(config["score_gates"])
44
45
  end
45
46
 
46
47
  attr_reader :cause
47
48
 
48
49
  def any?
49
- @limits.values.any?
50
+ @limits.values.any? || @score_gates.any?
51
+ end
52
+
53
+ # The flag-style name of the configured gate, for the --agent rejection.
54
+ def agent_option_name
55
+ @score_gates.any? ? "score gates" : "--max-* gates"
50
56
  end
51
57
 
52
58
  # --max-errors judges errored mutants, so a threshold run tolerates them.
@@ -56,6 +62,7 @@ module Saboteur
56
62
 
57
63
  # Prints each failed gate to err and remembers the first one's cause for the JSON report.
58
64
  def failed?(report, err)
65
+ vacuous_gates(report).each { |note| err.puts("saboteur: #{note}") }
59
66
  found = violations(report)
60
67
  found.each { |violation| err.puts("saboteur: #{violation.message}") }
61
68
  @cause = found.first&.cause
@@ -69,11 +76,45 @@ module Saboteur
69
76
  next if limit.nil? || !over?(count, limit, report.executed.size)
70
77
 
71
78
  Violation.new(cause: cause, message: "#{count} #{status} mutants, above #{flag} #{limit}")
72
- end
79
+ end + score_gate_violations(report)
73
80
  end
74
81
 
75
82
  private
76
83
 
84
+ # Per-path gates (#473): each gate scores the results whose subject file
85
+ # (root-relative) matches its pattern, with the run's own Score formula,
86
+ # and fails under its floor.
87
+ def score_gate_violations(report)
88
+ @score_gates.filter_map do |gate|
89
+ scored = score_gate_subset(gate, report)
90
+ breakdown = Score.of(scored)
91
+ next if breakdown.tested.zero?
92
+
93
+ score = breakdown.value * 100
94
+ next if score >= gate["min_score"]
95
+
96
+ Violation.new(cause: "scoreGate",
97
+ message: "score gate #{gate['pattern']}: score #{format('%.1f', score)} below " \
98
+ "#{gate['min_score']} (#{scored.size} mutants under it)")
99
+ end
100
+ end
101
+
102
+ # A gate with nothing executed under it passes vacuously and says so.
103
+ def vacuous_gates(report)
104
+ @score_gates.filter_map do |gate|
105
+ breakdown = Score.of(score_gate_subset(gate, report))
106
+ "score gate #{gate['pattern']}: nothing executed under it, passing vacuously" if breakdown.tested.zero?
107
+ end
108
+ end
109
+
110
+ def score_gate_subset(gate, report)
111
+ prefix = File.join(File.realpath(report.root), "")
112
+ report.results.select do |result|
113
+ relative = result.subject.file.delete_prefix(prefix)
114
+ File.fnmatch?(gate["pattern"], relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
115
+ end
116
+ end
117
+
77
118
  def over?(count, limit, executed)
78
119
  return count > limit if limit.is_a?(Integer)
79
120
 
data/lib/saboteur/help.rb CHANGED
@@ -25,7 +25,9 @@ module Saboteur
25
25
  2 => "Usage or configuration error: fix the command or config/saboteur.rb. A replay --id that matches no " \
26
26
  "mutant is only found after the target has booted. A named source or test file that does not exist.",
27
27
  3 => "Tool error: saboteur could not finish, or more than half of the executed mutants errored (such " \
28
- "a run tested almost nothing). With --threshold, also any errored or resource-limited mutant.",
28
+ "a run tested almost nothing), or a --since run skipped every subject of the change (for example " \
29
+ "refinements or no coverage), so the change is unverified. With --threshold, also any errored or " \
30
+ "resource-limited mutant.",
29
31
  4 => "Time budget spent: --max-duration stopped the run before the next subject, so the score is " \
30
32
  "partial. --max-duration-ok leaves the exit to the plain verdict: 0, 1, or 3 when the budget " \
31
33
  "stopped the run before any mutant was tested."
@@ -44,7 +46,8 @@ module Saboteur
44
46
  description: "Builds a per-test coverage map, makes small changes to your methods, runs only the " \
45
47
  "tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
46
48
  "directories after run replace include and exclude. Without --test, the run infers the " \
47
- "specs named after the mutated files and runs only those; a mutated file with no " \
49
+ "specs named after the mutated files and runs only those (with --since, plus every " \
50
+ "spec file the diff adds or changes that the suite loads); a mutated file with no " \
48
51
  "conventional spec stops the run with exit 2 before anything boots, and --all keeps " \
49
52
  "the whole configured suite. --threshold PERCENT replaces the survivor check: exit 0 " \
50
53
  "when the score is at or above PERCENT, exit 1 when it is below. All operators run " \
@@ -57,6 +60,7 @@ module Saboteur
57
60
  ["Pull request: only methods changed since the target branch, with the inferred specs",
58
61
  "saboteur run --since origin/main"],
59
62
  ["Whole project and whole suite, four workers, repeatable seed", "saboteur run --all --jobs 4 --seed 42"],
63
+ ["One deterministic CI shard of four", "saboteur run --all --shard 1/4"],
60
64
  ["One class or method", "saboteur run --subject 'Person#adult?'"],
61
65
  ["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
62
66
  ["Mutate named files against named specs, skipping the whole-suite coverage run",
@@ -68,13 +72,12 @@ module Saboteur
68
72
  Command.new(
69
73
  name: "pr",
70
74
  summary: "Preset: the fast pull-request loop over the lines changed since a ref",
71
- description: "Expands to saboteur run --baseline-passes 1 --max-duration 10m --max-duration-ok " \
75
+ description: "Expands to saboteur run --baseline-passes 1 --max-duration 10m " \
72
76
  "--since REF, with REF the first operand (default main): one baseline check, a " \
73
77
  "ten-minute wall-clock budget, and mutants limited to the changed methods. The " \
74
- "preset opts out of the budget-specific exit 4; the normal and incomplete " \
75
- "verdicts still fail the run (survivors exit 1, a budget stop before any mutant " \
76
- "ran exits 3). Every run option still works; an explicit flag after the preset " \
77
- "wins (#244).",
78
+ "preset fails with exit 4 when the budget is spent; the summary counts the " \
79
+ "unrun mutants. Add --max-duration-ok to accept a partial verdict; other " \
80
+ "gates still apply. Every run option still works; explicit flags win (#654).",
78
81
  takes_options: true,
79
82
  exit_codes: EXIT_CODE_MEANINGS,
80
83
  examples: [["Changed lines since main", "saboteur pr"],
@@ -99,7 +102,8 @@ module Saboteur
99
102
  summary: "Check the environment, and with --suite run the baseline twice, without mutating anything",
100
103
  description: "Boots the target like a run and checks runtime, coverage and boot files. With --suite " \
101
104
  "it also runs your suite twice in the same order with one seed and reports whether both " \
102
- "runs passed; --test PATH limits those runs to some specs. It never mutates anything.",
105
+ "runs passed; --test PATH limits those runs to some specs. It never mutates anything. " \
106
+ "It certifies no versions of Rails, databases or tools saboteur never invokes (#318).",
103
107
  takes_options: true,
104
108
  exit_codes: DOCTOR_EXIT_CODE_MEANINGS,
105
109
  examples: [["Check the setup before the first run", "saboteur doctor"],
@@ -128,6 +132,53 @@ module Saboteur
128
132
  ["Also fail on a score drop of more than 2 points",
129
133
  "saboteur baseline check --baseline baseline.json --epsilon 2"]]
130
134
  ),
135
+ Command.new(
136
+ name: "survivors",
137
+ summary: "Browse a report's survivors in the terminal, read-only",
138
+ description: "Reads the Stryker JSON report without booting the project or rerunning tests. " \
139
+ "Lists every survivor with rank, file, line, operator, digest and triage label; " \
140
+ "filters with --file, --operator and --triage; --ranked uses the report's ranked " \
141
+ "findings order. --show DIGEST prints the diff, the exact replay command and an " \
142
+ "ignore-file entry template for that mutant. It never edits the ignore file.",
143
+ takes_options: false,
144
+ exit_codes: { 0 => "Survivors listed or shown (including none).",
145
+ 2 => "Missing, malformed or unreadable report; bad arguments." },
146
+ examples: [["List survivors from the last run", "saboteur survivors .saboteur/report.json"],
147
+ ["Review-priority order", "saboteur survivors .saboteur/report.json --ranked"],
148
+ ["Diff, replay command and ignore template for one mutant",
149
+ "saboteur survivors .saboteur/report.json --show 1a2b3c4d5e6f7a8b"]]
150
+ ),
151
+ Command.new(
152
+ name: "badge",
153
+ summary: "Print a shields.io endpoint badge over the opt-in score history",
154
+ description: "Reads the score history JSONL (c.score_history, or --history PATH) and prints one " \
155
+ "shields.io endpoint badge JSON for the latest record: label, mutation score and " \
156
+ "color (brightgreen at or above --high, default 80; yellow at or above --low, " \
157
+ "default 60; red below). --output PATH writes the file instead of printing. An " \
158
+ "incomplete latest record is refused unless --mark-partial labels the badge as " \
159
+ "partial. No hosted service, no project boot, no tests run.",
160
+ takes_options: true,
161
+ exit_codes: { 0 => "Badge JSON written.",
162
+ 2 => "No history path or unexpected arguments.",
163
+ 3 => "Empty or unreadable history, an incomplete record without --mark-partial, " \
164
+ "or no tested mutants." },
165
+ examples: [["Badge the recorded score", "saboteur badge"],
166
+ ["Write the badge file CI uploads", "saboteur badge --output .saboteur/badge.json"]]
167
+ ),
168
+ Command.new(
169
+ name: "trend",
170
+ summary: "Print the score trend over the opt-in score history",
171
+ description: "Reads the score history JSONL (c.score_history, or --history PATH) and prints the " \
172
+ "latest record next to the most recent comparable one (same configured selection, " \
173
+ "operator policy and a complete run), with the covered-score change in percentage " \
174
+ "points. With no comparable earlier record it says so instead of inventing a " \
175
+ "delta. No hosted service, no project boot, no tests run.",
176
+ takes_options: true,
177
+ exit_codes: { 0 => "Trend printed.",
178
+ 2 => "No history path or unexpected arguments.",
179
+ 3 => "Empty or unreadable history." },
180
+ examples: [["Show the latest score and its change", "saboteur trend"]]
181
+ ),
131
182
  Command.new(
132
183
  name: "version",
133
184
  summary: "Print the installed version",
@@ -165,7 +216,7 @@ module Saboteur
165
216
  ["config/saboteur_ignore.rb", "Accepted survivors: mutation digests with subject and a required reason."],
166
217
  [".saboteur/report.json", "Stryker mutation-testing-report-schema JSON (--json changes the path)."],
167
218
  [".saboteur/report.html", "Self-contained HTML report (--html changes the path)."],
168
- [".saboteur/", "Cache: coverage map and incremental verdicts. Safe to delete."]
219
+ [".saboteur/", "Cache: coverage map and incremental verdicts. Safe to delete between runs; stop the run first."]
169
220
  ].freeze
170
221
 
171
222
  # option_lines: the option lines of each command's parser, keyed by command name.
@@ -21,7 +21,15 @@ module Saboteur
21
21
  #
22
22
  # reason is mandatory; a file with a missing or blank reason does not load.
23
23
  class IgnoreList
24
- Entry = Data.define(:subject, :mutation, :reason)
24
+ # Triage states beyond plain ignoring (#155): accepted_equivalent and
25
+ # wont_fix suppress the survivor's exit failure (it still counts in the
26
+ # score), todo records the decision to fix and lets the survivor count.
27
+ STATES = %w[accepted_equivalent wont_fix todo].freeze
28
+
29
+ Entry = Data.define(:subject, :mutation, :reason, :state) do
30
+ # Entries built without a state predate triage states: they accept.
31
+ def initialize(subject:, mutation:, reason:, state: "accepted_equivalent") = super
32
+ end
25
33
 
26
34
  # Collects the entries a saboteur_ignore.rb file declares.
27
35
  class Collector
@@ -31,16 +39,23 @@ module Saboteur
31
39
  @entries = []
32
40
  end
33
41
 
34
- def mutation(digest = nil, subject: nil, reason: nil)
42
+ def mutation(digest = nil, subject: nil, reason: nil, state: nil)
35
43
  line = caller_locations(1, 1).first.lineno
36
44
  values = { "subject" => subject, "mutation" => digest, "reason" => reason }
37
45
  missing = values.select { |_, value| value.to_s.strip.empty? }.keys
38
46
  raise ConfigError, "#{line}: entry is missing #{missing.join(', ')}" if missing.any?
39
47
 
48
+ state = state.to_s.strip
49
+ state = "accepted_equivalent" if state.empty?
50
+ unless STATES.include?(state)
51
+ raise ConfigError, "#{line}: unknown state #{state.inspect} - use one of #{STATES.join(', ')}"
52
+ end
53
+
40
54
  @entries << Entry.new(
41
55
  subject: subject.to_s.strip,
42
56
  mutation: digest.to_s.strip,
43
- reason: reason.to_s.strip
57
+ reason: reason.to_s.strip,
58
+ state: state
44
59
  )
45
60
  end
46
61
  end
@@ -96,9 +111,13 @@ module Saboteur
96
111
  @index = entries.to_h { |entry| [[entry.subject, entry.mutation], entry] }
97
112
  end
98
113
 
99
- # True when this result is a survivor the list accepts.
114
+ # True when this result is a survivor the list accepts: a todo entry
115
+ # records the triage but keeps the survivor counting as unignored.
100
116
  def accepts?(result)
101
- result.survived? && @index.key?([result.subject.id, result.digest])
117
+ return false unless result.survived?
118
+
119
+ entry = @index[[result.subject.id, result.digest]]
120
+ !entry.nil? && entry.state != "todo"
102
121
  end
103
122
 
104
123
  # Marks matching survivors as ignored. Returns warnings for stale entries:
@@ -114,7 +133,11 @@ module Saboteur
114
133
  next unless entry
115
134
 
116
135
  seen[key] = true
117
- result.ignored = entry.reason if result.survived?
136
+ next unless result.survived?
137
+
138
+ result.triage_state = entry.state
139
+ result.triage_reason = entry.reason
140
+ result.ignored = entry.reason if entry.state != "todo"
118
141
  end
119
142
  stale_warnings(results, seen, unscoped_digests)
120
143
  end