saboteur 0.11.0 → 0.11.2

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +2 -2
  4. data/lib/saboteur/baseline/command.rb +46 -2
  5. data/lib/saboteur/baseline/comparison.rb +6 -1
  6. data/lib/saboteur/baseline/completeness.rb +21 -0
  7. data/lib/saboteur/baseline/evidence.rb +11 -3
  8. data/lib/saboteur/baseline/loader.rb +18 -5
  9. data/lib/saboteur/baseline/manifest.rb +13 -4
  10. data/lib/saboteur/baseline/run_evidence.rb +3 -1
  11. data/lib/saboteur/baseline/workflow.rb +57 -9
  12. data/lib/saboteur/baseline_health.rb +4 -1
  13. data/lib/saboteur/cli/dirty_tree_notice.rb +7 -1
  14. data/lib/saboteur/cli/doctor_command.rb +10 -1
  15. data/lib/saboteur/cli/exit_status.rb +10 -5
  16. data/lib/saboteur/cli/explain_command.rb +51 -0
  17. data/lib/saboteur/cli/explicit_sources.rb +1 -1
  18. data/lib/saboteur/cli/failure_output.rb +2 -2
  19. data/lib/saboteur/cli/flaky_exit.rb +23 -0
  20. data/lib/saboteur/cli/presets.rb +11 -1
  21. data/lib/saboteur/cli/run_command.rb +17 -1
  22. data/lib/saboteur/cli/run_output.rb +10 -0
  23. data/lib/saboteur/cli/run_reporting.rb +13 -2
  24. data/lib/saboteur/cli/survivors_command.rb +82 -18
  25. data/lib/saboteur/cli.rb +3 -0
  26. data/lib/saboteur/config.rb +7 -2
  27. data/lib/saboteur/crash_report.rb +3 -2
  28. data/lib/saboteur/doctor.rb +11 -2
  29. data/lib/saboteur/engine/boot.rb +4 -2
  30. data/lib/saboteur/engine/coverage_store.rb +54 -3
  31. data/lib/saboteur/engine/distribution.rb +6 -2
  32. data/lib/saboteur/engine/pipelines.rb +14 -2
  33. data/lib/saboteur/engine/result_cache.rb +4 -2
  34. data/lib/saboteur/engine/scheduler.rb +23 -21
  35. data/lib/saboteur/engine/selection_announcement.rb +5 -4
  36. data/lib/saboteur/engine/subject_selection.rb +11 -1
  37. data/lib/saboteur/engine/survivor_confirmation.rb +1 -1
  38. data/lib/saboteur/engine/test_guess.rb +1 -1
  39. data/lib/saboteur/engine/thread_warnings.rb +9 -4
  40. data/lib/saboteur/engine.rb +57 -9
  41. data/lib/saboteur/execution/fiddle_loader.rb +29 -2
  42. data/lib/saboteur/help.rb +13 -1
  43. data/lib/saboteur/ignore_list.rb +36 -3
  44. data/lib/saboteur/install.rb +18 -3
  45. data/lib/saboteur/mutation_source/builtin.rb +1 -0
  46. data/lib/saboteur/mutation_source/operator_explain.rb +181 -0
  47. data/lib/saboteur/preview.rb +6 -1
  48. data/lib/saboteur/reporter/agent_commands.rb +29 -7
  49. data/lib/saboteur/reporter/agent_incomplete.rb +9 -2
  50. data/lib/saboteur/reporter/agent_summary.rb +2 -1
  51. data/lib/saboteur/reporter/baseline_tail.rb +9 -7
  52. data/lib/saboteur/reporter/gap_kinds.rb +80 -0
  53. data/lib/saboteur/reporter/html.rb +160 -10
  54. data/lib/saboteur/reporter/next_steps.rb +9 -7
  55. data/lib/saboteur/reporter/notice.rb +24 -0
  56. data/lib/saboteur/reporter/reuse_note.rb +37 -0
  57. data/lib/saboteur/reporter/review_comments.rb +17 -12
  58. data/lib/saboteur/reporter/score_table.rb +8 -7
  59. data/lib/saboteur/reporter/stryker.rb +11 -1
  60. data/lib/saboteur/reporter/survivor_classification.rb +26 -8
  61. data/lib/saboteur/reporter/survivor_hints.rb +3 -2
  62. data/lib/saboteur/reporter/survivor_list.rb +58 -4
  63. data/lib/saboteur/reporter/survivor_ranking.rb +13 -5
  64. data/lib/saboteur/reporter/terminal.rb +32 -33
  65. data/lib/saboteur/reporter/wording.rb +37 -0
  66. data/lib/saboteur/result.rb +39 -8
  67. data/lib/saboteur/selection/git_diff.rb +19 -1
  68. data/lib/saboteur/survivor_guidance.rb +33 -0
  69. data/lib/saboteur/text.rb +6 -0
  70. data/lib/saboteur/version.rb +1 -1
  71. metadata +10 -2
@@ -72,6 +72,16 @@ module Saboteur
72
72
  executed: executed, confirmation: confirmation)
73
73
  end
74
74
 
75
+ # The reason a --flaky-check disagreement starts with: unverified, but
76
+ # not because a budget ran out (#1167).
77
+ FLAKY_PREFIX = "flaky verdict"
78
+
79
+ def self.flaky_reason?(reason) = reason.to_s.start_with?(FLAKY_PREFIX)
80
+
81
+ # Unverified because the reordered confirmation run disagreed, not
82
+ # because --max-duration cut the check short.
83
+ def flaky? = status == :unverified && self.class.flaky_reason?(reason)
84
+
75
85
  # The same mutant, marked unverified: the budget ran out before its
76
86
  # verdict could be checked, so it must not count as detected (#296).
77
87
  def as_unverified(reason)
@@ -159,9 +169,9 @@ module Saboteur
159
169
  MAX_ERROR_FRACTION = 0.5
160
170
 
161
171
  attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :live_announced, :root, :seed,
162
- :skipped_subjects,
172
+ :skipped_subjects, :since,
163
173
  :generation_events, :omissions, :run_evidence, :hazard_suppressions, :early_stop, :inline_suppressions,
164
- :operator_policy, :discovery_failures, :memory, :shard, :test_command, :referenced
174
+ :operator_policy, :discovery_failures, :memory, :shard, :test_command, :referenced, :reuse_label
165
175
 
166
176
  # REVIEW: diff mode; the terminal then shows one survivor per line.
167
177
  # seed: the random seed every test run started from (nil when unknown).
@@ -170,7 +180,7 @@ module Saboteur
170
180
  seed: nil, skipped_subjects: {}, diff_scope: nil, generation_events: [], omissions: [],
171
181
  run_evidence: nil, hazard_suppressions: 0, early_stop: nil, matrix: false, inline_suppressions: [],
172
182
  operator_policy: nil, discovery_failures: [], memory: nil, shard: nil,
173
- test_command: nil, referenced: {})
183
+ test_command: nil, referenced: {}, reuse_label: nil, since: nil)
174
184
  @results = results
175
185
  @subjects = subjects
176
186
  @wall_time = wall_time
@@ -182,15 +192,12 @@ module Saboteur
182
192
  @seed = seed
183
193
  @skipped_subjects = skipped_subjects
184
194
  @diff_scope = diff_scope
185
- @generation_events = generation_events.dup.freeze
186
- @omissions = omissions.dup.freeze
195
+ take_frozen_copies(generation_events, omissions, inline_suppressions, discovery_failures)
187
196
  @run_evidence = run_evidence
188
197
  @hazard_suppressions = hazard_suppressions
189
198
  @early_stop = early_stop
190
199
  @matrix = matrix
191
- @inline_suppressions = inline_suppressions.dup.freeze
192
200
  @operator_policy = operator_policy
193
- @discovery_failures = discovery_failures.dup.freeze
194
201
  # Per-worker and supervisor peak RSS in MB, when the platform measured
195
202
  # it (#202); nil elsewhere, so JSON reports omit the section.
196
203
  @memory = memory.dup.freeze
@@ -198,9 +205,14 @@ module Saboteur
198
205
  # The external command of a --test-command run, nil for in-process
199
206
  # runs. Its presence means survivors carry no coverage map (#641).
200
207
  @test_command = test_command
208
+ # The --since ref of a diff run, nil for a whole-project run. The
209
+ # expand hint and the HTML report echo it, so the scope survives the
210
+ # run that produced them.
211
+ @since = since
201
212
  # file => specs, for files whose specs were chosen by constant reference
202
213
  # or require instead of by name (zero-setup selection).
203
214
  @referenced = referenced
215
+ @reuse_label = reuse_label
204
216
  end
205
217
 
206
218
  # A --matrix run: every covering test ran per mutant.
@@ -208,8 +220,16 @@ module Saboteur
208
220
  @matrix
209
221
  end
210
222
 
223
+ # The skipped subjects the live selection notice did not already name.
224
+ def unannounced_skips
225
+ skipped_subjects.reject do |subject, reason|
226
+ prefix = Text.display_safe_lines("#{subject.id} (#{subject.location(root)}): skipped (#{reason})")
227
+ live_announced.any? { |line| line.start_with?(prefix) }
228
+ end
229
+ end
230
+
211
231
  # True when the run was scoped to a diff (--since): the expand command
212
- # echoes that scope.
232
+ # echoes that scope's ref.
213
233
  def diff?
214
234
  !@diff_scope.nil?
215
235
  end
@@ -288,5 +308,16 @@ module Saboteur
288
308
  timed = executed.reject(&:reused?).filter_map(&:runtime)
289
309
  timed.empty? ? nil : timed.sum / timed.size
290
310
  end
311
+
312
+ # The run lists arrive from builders that may reuse them; the report
313
+ # keeps its own frozen copies.
314
+ private
315
+
316
+ def take_frozen_copies(generation_events, omissions, inline_suppressions, discovery_failures)
317
+ @generation_events = generation_events.dup.freeze
318
+ @omissions = omissions.dup.freeze
319
+ @inline_suppressions = inline_suppressions.dup.freeze
320
+ @discovery_failures = discovery_failures.dup.freeze
321
+ end
291
322
  end
292
323
  end
@@ -15,6 +15,9 @@ module Saboteur
15
15
  NO_FILE = "/dev/null" # rubocop:disable Style/FileNull -- literal in git diff output
16
16
  HUNK = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
17
17
 
18
+ LOCAL_BRANCH_CAP = 5
19
+ BASE_NAMES = %w[main master trunk develop].freeze
20
+
18
21
  module_function
19
22
 
20
23
  # Fails before any boot work when REF is not a commit here, as a usage
@@ -38,12 +41,22 @@ module Saboteur
38
41
  message = "--since #{ref}: not a commit in this repository " \
39
42
  "(check the name; a shallow checkout may need a deeper fetch)"
40
43
  branch = default_branch(root)
41
- return message unless branch
44
+ return "#{message}#{local_branches_hint(root)}" unless branch
42
45
 
43
46
  "#{message}; this repository's default branch is '#{branch}' - " \
44
47
  "pass it explicitly: saboteur pr #{branch}"
45
48
  end
46
49
 
50
+ # A local-only repo has no origin/HEAD to name: list the branches it
51
+ # does have, so a bare `saboteur pr` (default main) points at a real one.
52
+ def local_branches_hint(root)
53
+ out, _, ok = Open3.capture3("git", "for-each-ref", "--format=%(refname:short)", "refs/heads", chdir: root)
54
+ branches = ok.success? ? likely_bases(out.split("\n")).first(LOCAL_BRANCH_CAP) : []
55
+ return "" if branches.empty?
56
+
57
+ "; local branches: #{branches.join(', ')} - pass one explicitly: saboteur pr #{branches.first}"
58
+ end
59
+
47
60
  # The default branch origin publishes, without the remote name, or nil
48
61
  # when origin has no HEAD (a local-only clone keeps the plain hint).
49
62
  def default_branch(root)
@@ -54,6 +67,11 @@ module Saboteur
54
67
  branch.empty? ? nil : branch
55
68
  end
56
69
 
70
+ # Conventional base names first, so the suggested example is a base.
71
+ def likely_bases(branches)
72
+ branches.sort_by { |name| [BASE_NAMES.index(name) || BASE_NAMES.size, name] }
73
+ end
74
+
57
75
  def merge_base(ref, root:)
58
76
  git(root, "merge-base", ref, "HEAD").strip
59
77
  end
@@ -0,0 +1,33 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # One wording for the two survivor follow-ups a user copies: replaying
6
+ # one mutant and accepting one in the ignore file. The run screen (Next
7
+ # steps) and `saboteur survivors --show` share these strings so the two
8
+ # surfaces never drift (#1167).
9
+ module SurvivorGuidance
10
+ REPLAY_CAVEAT = "(add this run's own --config, --since, --subject, --test and source options)"
11
+ IGNORE_INTRO = "accept an equivalent one: add to your ignore file " \
12
+ "(c.ignore_file, default #{Config::IGNORE_FILE}):".freeze
13
+ REASON_PLACEHOLDER = "why it is equivalent"
14
+
15
+ module_function
16
+
17
+ def replay_command(digest, seed: nil)
18
+ "saboteur run --id #{digest}#{" --seed #{seed}" if seed}"
19
+ end
20
+
21
+ # One line, paste-ready. todo names what the user must still fill in
22
+ # (a capped report omits the subject for some digests).
23
+ def ignore_entry(digest, subject_id, todo: nil)
24
+ entry = %(i.mutation "#{digest}", subject: "#{subject_id}", reason: "#{REASON_PLACEHOLDER}")
25
+ todo ? "#{entry} # TODO: #{todo}" : entry
26
+ end
27
+
28
+ # The entry wrapped so it pastes into the ignore file as-is (#1194).
29
+ def ignore_block(digest, subject_id, todo: nil)
30
+ "Saboteur.ignore do |i|\n #{ignore_entry(digest, subject_id, todo: todo)}\nend"
31
+ end
32
+ end
33
+ end
data/lib/saboteur/text.rb CHANGED
@@ -11,6 +11,12 @@ module Saboteur
11
11
  module Text
12
12
  module_function
13
13
 
14
+ # "1 subject" / "2 subjects": the count with the noun in the right
15
+ # number (review C9). An irregular plural passes explicitly.
16
+ def counted(count, singular, plural = "#{singular}s")
17
+ "#{count} #{count == 1 ? singular : plural}"
18
+ end
19
+
14
20
  # A list for a warning or note: the first five items, then "and N more".
15
21
  # One rule for every message that names files, subjects or specs (#332).
16
22
  # Items are repo-controlled text, so each is display_safe (#834).
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.11.0"
5
+ VERSION = "0.11.2"
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saboteur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -68,9 +68,11 @@ files:
68
68
  - lib/saboteur/cli/error_cause.rb
69
69
  - lib/saboteur/cli/execution_options.rb
70
70
  - lib/saboteur/cli/exit_status.rb
71
+ - lib/saboteur/cli/explain_command.rb
71
72
  - lib/saboteur/cli/explicit_file_options.rb
72
73
  - lib/saboteur/cli/explicit_sources.rb
73
74
  - lib/saboteur/cli/failure_output.rb
75
+ - lib/saboteur/cli/flaky_exit.rb
74
76
  - lib/saboteur/cli/help_command.rb
75
77
  - lib/saboteur/cli/help_options.rb
76
78
  - lib/saboteur/cli/history_command.rb
@@ -203,6 +205,7 @@ files:
203
205
  - lib/saboteur/mutation_source/operand_replacement.rb
204
206
  - lib/saboteur/mutation_source/operator_assignment.rb
205
207
  - lib/saboteur/mutation_source/operator_examples.rb
208
+ - lib/saboteur/mutation_source/operator_explain.rb
206
209
  - lib/saboteur/mutation_source/operator_registry.rb
207
210
  - lib/saboteur/mutation_source/operator_visit.rb
208
211
  - lib/saboteur/mutation_source/pair_removal.rb
@@ -224,6 +227,7 @@ files:
224
227
  - lib/saboteur/reporter/baseline_tail.rb
225
228
  - lib/saboteur/reporter/diff.rb
226
229
  - lib/saboteur/reporter/findings.rb
230
+ - lib/saboteur/reporter/gap_kinds.rb
227
231
  - lib/saboteur/reporter/history_paths.rb
228
232
  - lib/saboteur/reporter/history_policy.rb
229
233
  - lib/saboteur/reporter/history_reader.rb
@@ -232,8 +236,10 @@ files:
232
236
  - lib/saboteur/reporter/kill_causes.rb
233
237
  - lib/saboteur/reporter/mutation_text.rb
234
238
  - lib/saboteur/reporter/next_steps.rb
239
+ - lib/saboteur/reporter/notice.rb
235
240
  - lib/saboteur/reporter/palette.rb
236
241
  - lib/saboteur/reporter/replay.rb
242
+ - lib/saboteur/reporter/reuse_note.rb
237
243
  - lib/saboteur/reporter/review_comments.rb
238
244
  - lib/saboteur/reporter/safe_out.rb
239
245
  - lib/saboteur/reporter/score_history.rb
@@ -245,6 +251,7 @@ files:
245
251
  - lib/saboteur/reporter/survivor_ranking.rb
246
252
  - lib/saboteur/reporter/terminal.rb
247
253
  - lib/saboteur/reporter/test_matrix.rb
254
+ - lib/saboteur/reporter/wording.rb
248
255
  - lib/saboteur/result.rb
249
256
  - lib/saboteur/score.rb
250
257
  - lib/saboteur/selection/coverage_map.rb
@@ -258,6 +265,7 @@ files:
258
265
  - lib/saboteur/selection/untracked_changes.rb
259
266
  - lib/saboteur/subject.rb
260
267
  - lib/saboteur/subject_finder.rb
268
+ - lib/saboteur/survivor_guidance.rb
261
269
  - lib/saboteur/text.rb
262
270
  - lib/saboteur/version.rb
263
271
  - lib/saboteur/working_tree.rb