saboteur 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +166 -40
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +49 -4
  8. data/lib/saboteur/cli/help_options.rb +16 -0
  9. data/lib/saboteur/cli/presets.rb +36 -0
  10. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  11. data/lib/saboteur/cli/run_reporting.rb +74 -5
  12. data/lib/saboteur/cli.rb +67 -46
  13. data/lib/saboteur/config.rb +66 -11
  14. data/lib/saboteur/crash_report.rb +154 -0
  15. data/lib/saboteur/custom_map.rb +3 -1
  16. data/lib/saboteur/doctor.rb +40 -1
  17. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  18. data/lib/saboteur/engine/boot.rb +12 -1
  19. data/lib/saboteur/engine/canary.rb +41 -10
  20. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  21. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  22. data/lib/saboteur/engine/coverage_store.rb +48 -6
  23. data/lib/saboteur/engine/diagnostics.rb +23 -2
  24. data/lib/saboteur/engine/diff_scope.rb +24 -5
  25. data/lib/saboteur/engine/distribution.rb +124 -8
  26. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  27. data/lib/saboteur/engine/result_cache.rb +2 -1
  28. data/lib/saboteur/engine/run_budget.rb +63 -0
  29. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  30. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  31. data/lib/saboteur/engine/scheduler.rb +114 -18
  32. data/lib/saboteur/engine/subject_selection.rb +84 -5
  33. data/lib/saboteur/engine/suite_files.rb +3 -1
  34. data/lib/saboteur/engine/test_guess.rb +145 -22
  35. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  36. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  37. data/lib/saboteur/engine/verdict_check.rb +54 -18
  38. data/lib/saboteur/engine.rb +166 -95
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/runner.rb +179 -17
  41. data/lib/saboteur/execution/sandbox.rb +6 -1
  42. data/lib/saboteur/execution/supervisor.rb +18 -3
  43. data/lib/saboteur/execution/worker_pool.rb +87 -7
  44. data/lib/saboteur/help.rb +55 -11
  45. data/lib/saboteur/ignore_list.rb +11 -1
  46. data/lib/saboteur/install.rb +4 -3
  47. data/lib/saboteur/integration/base.rb +7 -2
  48. data/lib/saboteur/integration/rspec.rb +40 -4
  49. data/lib/saboteur/mutation_source/builtin.rb +20 -11
  50. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  51. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  52. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  53. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  54. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  55. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  56. data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
  57. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  58. data/lib/saboteur/preview.rb +2 -1
  59. data/lib/saboteur/reporter/agent_commands.rb +38 -13
  60. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  61. data/lib/saboteur/reporter/next_steps.rb +14 -4
  62. data/lib/saboteur/reporter/score_history.rb +5 -1
  63. data/lib/saboteur/reporter/stryker.rb +13 -4
  64. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  65. data/lib/saboteur/reporter/terminal.rb +34 -10
  66. data/lib/saboteur/result.rb +22 -7
  67. data/lib/saboteur/selection/coverage_map.rb +2 -1
  68. data/lib/saboteur/selection/git_diff.rb +15 -5
  69. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  70. data/lib/saboteur/subject.rb +11 -0
  71. data/lib/saboteur/text.rb +42 -0
  72. data/lib/saboteur/version.rb +1 -1
  73. data/lib/saboteur.rb +16 -1
  74. metadata +17 -2
@@ -7,9 +7,14 @@ module Saboteur
7
7
  # accept one, and how to hand the run to an agent. Silent when nothing
8
8
  # survived, so a clean run stays short.
9
9
  class NextSteps
10
- def initialize(out, palette)
10
+ # replay_command: digest -> the full, copy-pasteable replay command
11
+ # carrying the run's own selection options, however long it gets
12
+ # (#261). When no builder is wired the digest-only hint (with its
13
+ # reassembly note) is the fallback.
14
+ def initialize(out, palette, replay_command: nil)
11
15
  @out = out
12
16
  @palette = palette
17
+ @replay_command = replay_command
13
18
  end
14
19
 
15
20
  def call(report)
@@ -17,9 +22,14 @@ module Saboteur
17
22
  return unless survivor
18
23
 
19
24
  @out.puts "\n#{@palette.paint(:bold, 'Next steps:')}"
20
- seed = " --seed #{report.seed}" if report.seed
21
- @out.puts " replay one survivor: saboteur run --id #{survivor.digest}#{seed}"
22
- @out.puts " (add this run's own --config, --since, --subject, --test and source options)"
25
+ command = @replay_command&.call(survivor.digest)
26
+ if command
27
+ @out.puts " replay one survivor: #{command}"
28
+ else
29
+ seed = " --seed #{report.seed}" if report.seed
30
+ @out.puts " replay one survivor: saboteur run --id #{survivor.digest}#{seed}"
31
+ @out.puts " (add this run's own --config, --since, --subject, --test and source options)"
32
+ end
23
33
  @out.puts " accept an equivalent one: add to your ignore file (c.ignore_file, default #{Config::IGNORE_FILE}):"
24
34
  entry = %(i.mutation "#{survivor.digest}", subject: "#{survivor.subject.id}", reason: "why it is equivalent")
25
35
  @out.puts " #{entry}"
@@ -39,7 +39,11 @@ module Saboteur
39
39
  file.flush
40
40
  change(previous, record)
41
41
  end
42
- rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError => e
42
+ # SystemCallError/IOError from the write itself propagate unwrapped on
43
+ # purpose: the CLI treats those as a soft artifact failure (warn and
44
+ # exit by the results), while validation, integrity, record-size and
45
+ # lock failures stay hard errors (#296).
46
+ rescue JSON::ParserError, KeyError, TypeError => e
43
47
  raise Error, "score history was not recorded: #{e.message}"
44
48
  end
45
49
 
@@ -58,7 +58,14 @@ module Saboteur
58
58
  # nil for a run that covered its whole selection.
59
59
  def self.early_stop(report)
60
60
  stop = report.early_stop
61
- stop && { reason: stop.reason, unreachedSubjects: stop.unreached }
61
+ return unless stop
62
+
63
+ { reason: stop.reason, unreachedSubjects: stop.unreached, truncatedSubjects: truncated_subjects(stop),
64
+ unverifiedSubjects: stop.unverified }
65
+ end
66
+
67
+ def self.truncated_subjects(stop)
68
+ stop.truncated.map { |entry| { subject: entry[:subject], omittedMutants: entry[:omitted] } }
62
69
  end
63
70
 
64
71
  def self.skipped_subjects(report)
@@ -115,8 +122,9 @@ module Saboteur
115
122
  when :error, :resource_limit then "RuntimeError"
116
123
  when :skipped then result.reason&.start_with?("no coverage") ? "NoCoverage" : "Ignored"
117
124
  # The schema has no status for "ran, but no test can observe a
118
- # change here". Ignored with the reason is the honest mapping.
119
- when :ran_at_load, :unreachable then "Ignored"
125
+ # change here", nor for a verdict the budget left unchecked.
126
+ # Ignored with the reason is the honest mapping.
127
+ when :ran_at_load, :unreachable, :unverified then "Ignored"
120
128
  end
121
129
  end
122
130
 
@@ -125,7 +133,8 @@ module Saboteur
125
133
  return "resource limit: #{result.reason}" if result.status == :resource_limit
126
134
  return result.crash_kill? ? "crash: #{result.kill_cause}" : result.kill_cause if result.kill_cause
127
135
 
128
- result.reason.to_s.lines.first&.strip
136
+ reason = result.reason.to_s.strip
137
+ reason.empty? ? nil : reason
129
138
  end
130
139
 
131
140
  def self.location(edit, source)
@@ -19,6 +19,9 @@ module Saboteur
19
19
  "safe_navigation" => "try an example where the receiver is nil",
20
20
  "argument_removal" => "try have_received(...).with(...), or assert a result that depends on this argument",
21
21
  "pair_removal" => "try have_received(...).with(...), or assert the hash content under this key",
22
+ "chain_removal" => "try the shorter chain's value against the longer one; if you confirm the " \
23
+ "shorter chain is equivalent across the intended inputs (for example a duplicated " \
24
+ "column), simplify the code or ignore the mutant",
22
25
  "endpoint_replacement" => "try a collection with 2 or more elements, to tell first from last",
23
26
  "filter_replacement" => "try items that match and items that do not, to tell select from reject"
24
27
  }.freeze
@@ -17,12 +17,12 @@ module Saboteur
17
17
  MAX_ERRORS = 10
18
18
  MAX_PASSING_TESTS = SurvivorList::MAX_PASSING_TESTS
19
19
 
20
- def initialize(out, verbose: false, color: :auto)
20
+ def initialize(out, verbose: false, color: :auto, replay_command: nil)
21
21
  @out = out
22
22
  @verbose = verbose
23
23
  @palette = Palette.for(out, mode: color)
24
24
  @score_table = ScoreTable.new(out, @palette)
25
- @next_steps = NextSteps.new(out, @palette)
25
+ @next_steps = NextSteps.new(out, @palette, replay_command: replay_command)
26
26
  end
27
27
 
28
28
  def call(report)
@@ -109,6 +109,7 @@ module Saboteur
109
109
  "resource limit #{c[:resource_limit]} error #{c[:error]} skipped #{c[:skipped]}"
110
110
  line += " ran at load #{c[:ran_at_load]}" if c[:ran_at_load].positive?
111
111
  line += " unreachable #{c[:unreachable]}" if c[:unreachable].positive?
112
+ line += " unverified #{c[:unverified]}" if c[:unverified].positive?
112
113
  line
113
114
  end
114
115
 
@@ -223,17 +224,39 @@ module Saboteur
223
224
  end
224
225
  end
225
226
 
226
- # The verdict covers only the subjects that ran, so say it where the
227
+ # The verdict covers only the completed work, so say it where the
227
228
  # score is read.
228
229
  def early_stop(report)
229
230
  stop = report.early_stop
230
231
  return unless stop
231
232
 
232
- @out.puts @palette.paint(:yellow, "stopped early (--fail-fast): #{stop.unreached.size} selected " \
233
- "subject#{'s' unless stop.unreached.one?} did not run; the score below " \
234
- "covers only the subjects that ran")
235
- stop.unreached.first(MAX_ERRORS).each { |id| @out.puts " not run: #{id}" }
236
- @out.puts " ... #{stop.unreached.size - MAX_ERRORS} more" if stop.unreached.size > MAX_ERRORS
233
+ label = stop.reason == "fail_fast" ? "--fail-fast" : "--max-duration spent"
234
+ @out.puts @palette.paint(:yellow, "stopped early (#{label}): #{early_stop_summary(stop)}; the score below " \
235
+ "covers only completed work")
236
+ list(stop.unreached) { |id| " not run: #{id}" }
237
+ list(stop.truncated) { |entry| " stopped mid-way: #{entry[:subject]} (#{entry[:omitted]} mutants omitted)" }
238
+ list(stop.unverified) { |id| " unverified: #{id}" }
239
+ end
240
+
241
+ def list(entries)
242
+ entries.first(MAX_ERRORS).each { |entry| @out.puts yield(entry) }
243
+ @out.puts " ... #{entries.size - MAX_ERRORS} more" if entries.size > MAX_ERRORS
244
+ end
245
+
246
+ def early_stop_summary(stop)
247
+ parts = []
248
+ if stop.unreached.any?
249
+ parts << "#{stop.unreached.size} selected subject#{'s' unless stop.unreached.one?} did not run"
250
+ end
251
+ if stop.truncated.any?
252
+ omitted = stop.truncated.sum { |entry| entry[:omitted] }
253
+ parts << "#{stop.truncated.size} subject#{'s' unless stop.truncated.one?} stopped mid-way " \
254
+ "(#{omitted} mutants omitted)"
255
+ end
256
+ if stop.unverified.any?
257
+ parts << "#{stop.unverified.size} subject#{'s' unless stop.unverified.one?} left with unverified results"
258
+ end
259
+ parts.join("; ")
237
260
  end
238
261
 
239
262
  def warnings(report)
@@ -246,8 +269,9 @@ module Saboteur
246
269
 
247
270
  @out.puts "\nErrors (not counted as killed):"
248
271
  errored.first(MAX_ERRORS).each do |result|
249
- @out.puts " #{result.subject.id} #{result.mutation.operator} #{result.digest}: " \
250
- "#{result.reason.to_s.lines.first&.strip}"
272
+ lines = result.reason.to_s.lines.map(&:strip)
273
+ @out.puts " #{result.subject.id} #{result.mutation.operator} #{result.digest}: #{lines.first}"
274
+ lines.drop(1).each { |line| @out.puts " #{line}" }
251
275
  end
252
276
  @out.puts " ... #{errored.size - MAX_ERRORS} more" if errored.size > MAX_ERRORS
253
277
  end
@@ -12,7 +12,10 @@ module Saboteur
12
12
  # test can observe a change there, so it is outside the score.
13
13
  # unreachable: a Method object captured before re-evaluation still calls
14
14
  # the original body, so no test can observe the mutant. Outside the score.
15
- STATUSES = %i[killed survived timeout resource_limit error skipped ran_at_load unreachable].freeze
15
+ # unverified: the --max-duration budget ran out before the verdict check
16
+ # or timeout calibration could run, so the kill or timeout proves
17
+ # nothing. Outside the score; the run reports budget-partial.
18
+ STATUSES = %i[killed survived timeout resource_limit error skipped ran_at_load unreachable unverified].freeze
16
19
 
17
20
  # Outcome of one mutation. reason carries the skip reason, the error
18
21
  # message or the timeout limit. ignored is the ignore-list reason, if any.
@@ -40,6 +43,14 @@ module Saboteur
40
43
  @ignored = nil
41
44
  end
42
45
 
46
+ # The same mutant, marked unverified: the budget ran out before its
47
+ # verdict could be checked, so it must not count as detected (#296).
48
+ def as_unverified(reason)
49
+ MutantResult.new(subject: subject, mutation: mutation, status: :unverified, mutated_source: mutated_source,
50
+ reason: reason, runtime: runtime, killed_by: killed_by, covered_by: covered_by,
51
+ kill_cause: kill_cause, executed: executed)
52
+ end
53
+
43
54
  # The same mutant, marked as an error: its verdict did not hold up.
44
55
  def as_error(reason, rejected_kill: false)
45
56
  MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: mutated_source,
@@ -100,10 +111,13 @@ module Saboteur
100
111
  end
101
112
  end
102
113
 
103
- # A run that stopped before every selected subject was mutated (--fail-fast).
104
- # unreached lists the subject ids that never ran; the verdict covers only
105
- # the subjects that did.
106
- EarlyStop = Data.define(:reason, :unreached)
114
+ # A run that stopped before every selected subject was mutated (--fail-fast,
115
+ # --max-duration), or whose budget ran out before every verdict check.
116
+ # unreached lists the subject ids that never ran; truncated lists the
117
+ # subjects that started but stopped mid-way as {subject:, omitted:}
118
+ # entries; unverified lists the subjects whose kills the budget left
119
+ # unchecked. The verdict covers only completed, verified work.
120
+ EarlyStop = Data.define(:reason, :unreached, :truncated, :unverified)
107
121
 
108
122
  # Everything one run produced.
109
123
  class Report
@@ -113,14 +127,14 @@ module Saboteur
113
127
 
114
128
  attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects,
115
129
  :generation_events, :omissions, :run_evidence, :hazard_suppressions, :early_stop, :inline_suppressions,
116
- :operator_policy
130
+ :operator_policy, :discovery_failures
117
131
 
118
132
  # REVIEW: diff mode; the terminal then shows one survivor per line.
119
133
  # seed: the random seed every test run started from (nil when unknown).
120
134
  def initialize(results:, subjects:, wall_time:, root:, invalid_mutations: 0, warnings: [], review: false,
121
135
  seed: nil, skipped_subjects: {}, diff_scope: nil, generation_events: [], omissions: [],
122
136
  run_evidence: nil, hazard_suppressions: 0, early_stop: nil, matrix: false, inline_suppressions: [],
123
- operator_policy: nil)
137
+ operator_policy: nil, discovery_failures: [])
124
138
  @results = results
125
139
  @subjects = subjects
126
140
  @wall_time = wall_time
@@ -139,6 +153,7 @@ module Saboteur
139
153
  @matrix = matrix
140
154
  @inline_suppressions = inline_suppressions.dup.freeze
141
155
  @operator_policy = operator_policy
156
+ @discovery_failures = discovery_failures.dup.freeze
142
157
  end
143
158
 
144
159
  # A --matrix run: every covering test ran per mutant.
@@ -5,6 +5,7 @@ require "coverage"
5
5
  require "digest"
6
6
  require "fileutils"
7
7
  require "json"
8
+ require_relative "../text"
8
9
 
9
10
  module Saboteur
10
11
  module Selection
@@ -168,7 +169,7 @@ module Saboteur
168
169
  end
169
170
 
170
171
  def self.load(path)
171
- from_json(File.read(path)) if File.file?(path)
172
+ from_json(Text.utf8_strict(File.read(path))) if File.file?(path)
172
173
  rescue JSON::ParserError, Error
173
174
  nil
174
175
  end
@@ -2,6 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "open3"
5
+ require_relative "../text"
6
+ require_relative "untracked_changes"
5
7
 
6
8
  module Saboteur
7
9
  module Selection
@@ -39,12 +41,18 @@ module Saboteur
39
41
  status.success? ? out.b : nil
40
42
  end
41
43
 
42
- def changed_lines(ref, root:)
44
+ def changed_lines(ref, root:, include: nil, diagnostics: nil)
43
45
  verify_ref!(ref, root: root)
44
46
  base = merge_base(ref, root: root)
45
- top = git(root, "rev-parse", "--show-toplevel").strip
46
- parse(git(root, "-c", "core.quotePath=false", "diff", "--unified=0", "--no-color", "--no-ext-diff", base,
47
- "--"), top)
47
+ # Root and paths are filesystem identity: strip as bytes and retag
48
+ # losslessly (strip raises on locale-tagged high bytes; a scrub
49
+ # would rename a raw-byte path). Parse the diff body as bytes and
50
+ # tag each extracted path UTF-8 at the boundary.
51
+ top = Text.utf8_identity(git(root, "rev-parse", "--show-toplevel").b.strip)
52
+ changes = parse(git(root, "-c", "core.quotePath=false", "diff", "--unified=0", "--no-color",
53
+ "--no-ext-diff", base, "--").b, top)
54
+ UntrackedChanges.union(changes, top, include: include, diagnostics: diagnostics)
55
+ changes
48
56
  end
49
57
 
50
58
  def parse(diff, top)
@@ -52,8 +60,10 @@ module Saboteur
52
60
  file = nil
53
61
  diff.each_line do |line|
54
62
  if line.start_with?("+++ ")
55
- path = unquote(line.chomp.delete_prefix("+++ ").delete_suffix("\t"))
63
+ path = Text.utf8_identity(unquote(line.chomp.delete_prefix("+++ ").delete_suffix("\t")))
56
64
  file = path == NO_FILE ? nil : File.join(top, path.delete_prefix("b/"))
65
+ # HUNK is anchored, so a non-hunk line - including a raw-byte
66
+ # content line - fails the match at its first byte.
57
67
  elsif file && (match = HUNK.match(line))
58
68
  start = match[1].to_i
59
69
  count = (match[2] || "1").to_i
@@ -0,0 +1,126 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../text"
5
+
6
+ module Saboteur
7
+ module Selection
8
+ # Untracked files as changed-line coverage for --since.
9
+ #
10
+ # A diff cannot see untracked files, so a never-committed source file
11
+ # would silently contribute no changed lines - and, being in no map,
12
+ # not even the changed-files-without-subjects warning (#295). Union
13
+ # every untracked file that could hold subjects (the include set the
14
+ # run will mutate) as fully changed: a new file is the strongest
15
+ # possible "changed since ref" signal. Ignored files stay invisible
16
+ # (--exclude-standard), and binary or empty files carry no lines,
17
+ # matching how a committed binary diffs to no hunks.
18
+ #
19
+ # Each file is opened RDONLY|NOFOLLOW|NONBLOCK and validated on that
20
+ # one descriptor, closing the listing-to-read race: a symlink swapped
21
+ # in after the listing fails the open, a FIFO opens without blocking
22
+ # and fails the regular-file stat, and a vanished or unreadable file
23
+ # is reported. Where a platform lacks either flag the union is skipped
24
+ # with a warning rather than done unsafely (the crash log makes the
25
+ # same trade, #296). The binary sniff reads only the first few
26
+ # kilobytes (git's own heuristic window) and the count streams, so a
27
+ # huge untracked source costs one pass, not its content in memory; the
28
+ # map holds a range, not one Set entry per line.
29
+ module UntrackedChanges
30
+ BINARY_SNIFF_BYTES = 8000
31
+ READ_CHUNK_BYTES = 64 * 1024
32
+
33
+ module_function
34
+
35
+ # Add every include-eligible untracked file to CHANGES as a full
36
+ # range. Eligible files that cannot be read are warned about and
37
+ # recorded as discovery failures, not dropped silently, so the run is
38
+ # reported incomplete instead of clean over mixed source.
39
+ def union(changes, top, include:, diagnostics:)
40
+ unless File.const_defined?(:NOFOLLOW) && File.const_defined?(:NONBLOCK)
41
+ diagnostics&.warn("--since: untracked files are not included: this platform lacks " \
42
+ "O_NOFOLLOW/O_NONBLOCK, so they cannot be opened race-free")
43
+ return
44
+ end
45
+
46
+ flags = File::RDONLY | File::NOFOLLOW | File::NONBLOCK
47
+ unreadable = []
48
+ files(top).each do |name|
49
+ file = File.join(top, name)
50
+ # Membership is lexical first: an included path swapped to a
51
+ # symlink must reach the safe open, which rejects it, so the swap
52
+ # is reported instead of filtered out on the resolved target. The
53
+ # canonical fallback keeps a symlinked ROOT in scope, because the
54
+ # include set is realpath'd.
55
+ next if include && !include.include?(file) && !include.include?(CoverageMap.canonical(file))
56
+
57
+ case (lines = line_count(file, flags))
58
+ when :unreadable then unreadable << name
59
+ when Integer then changes[file] = (1..lines)
60
+ end
61
+ end
62
+ unreadable.each do |name|
63
+ diagnostics&.record_discovery_failure(File.join(top, name), reason: "unreadable")
64
+ end
65
+ warn_unreadable(unreadable, diagnostics)
66
+ end
67
+
68
+ # Absolute paths of untracked, non-ignored files under ROOT: they
69
+ # never appear in the diff but can still sit in excluded territory.
70
+ def paths(root)
71
+ top = Text.utf8_identity(GitDiff.git(root, "rev-parse", "--show-toplevel").b.strip)
72
+ files(top).map { |name| File.join(top, name) }
73
+ end
74
+
75
+ def files(top)
76
+ GitDiff.git(top, "ls-files", "-z", "--others", "--exclude-standard").b.split("\0").map do |name|
77
+ Text.utf8_identity(name)
78
+ end
79
+ end
80
+
81
+ # An included untracked file that cannot be read (vanished,
82
+ # unreadable or replaced after the listing) is missing source
83
+ # coverage: a clean --since must not hide it.
84
+ def warn_unreadable(names, diagnostics)
85
+ return if names.empty? || diagnostics.nil?
86
+
87
+ shown = names.sort.first(5).join(", ")
88
+ shown += ", ..." if names.size > 5
89
+ diagnostics.warn("--since: #{names.size} included untracked file(s) could not be read (vanished, " \
90
+ "unreadable or replaced), so their subjects are not covered: #{shown}")
91
+ end
92
+
93
+ # Line count of an untracked file opened race-free: nil when the
94
+ # file stays out of the map by content (empty, or binary inside the
95
+ # sniff window), or :unreadable when it could not be validated
96
+ # (vanished, unreadable, a symlink, not a regular file).
97
+ def line_count(file, flags)
98
+ File.open(file, flags) do |io|
99
+ next :unreadable unless io.stat.file?
100
+
101
+ count_lines(io)
102
+ end
103
+ rescue SystemCallError, IOError
104
+ :unreadable
105
+ end
106
+
107
+ # Every read is bounded: the binary sniff takes only the first few
108
+ # kilobytes (git's own heuristic window; read of an empty file is
109
+ # nil, never ""), then the count streams in fixed chunks.
110
+ def count_lines(io)
111
+ first = io.read(BINARY_SNIFF_BYTES)
112
+ return if first.nil? || first.include?("\0")
113
+
114
+ lines = first.count("\n")
115
+ trailing_newline = first.end_with?("\n")
116
+ # A no-arg read returns "" at EOF, never nil - guard the loop so a
117
+ # mutant that drops the chunk size spins zero times, not forever.
118
+ while (chunk = io.read(READ_CHUNK_BYTES)) && !chunk.empty?
119
+ lines += chunk.count("\n")
120
+ trailing_newline = chunk.end_with?("\n")
121
+ end
122
+ trailing_newline ? lines : lines + 1
123
+ end
124
+ end
125
+ end
126
+ end
@@ -114,6 +114,17 @@ module Saboteur
114
114
  line.between?(start_line, end_line)
115
115
  end
116
116
 
117
+ # True when a changed-lines set intersects this subject's span. A
118
+ # whole-file range answers by interval overlap in O(1) instead of a
119
+ # per-line scan from line 1 for every subject.
120
+ def overlaps_lines?(lines)
121
+ if lines.is_a?(Range)
122
+ lines.begin <= end_line && start_line <= lines.end
123
+ else
124
+ lines.any? { |line| contains_line?(line) }
125
+ end
126
+ end
127
+
117
128
  # Applies the mutation's edits to this method's text and returns the
118
129
  # mutated method text. Raises ArgumentError for edits outside the method
119
130
  # or overlapping each other.
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module Saboteur
7
+ # Bytes arriving from outside the process (git output, cache and log files,
8
+ # worker pipes) are tagged with the process locale. Under a non-UTF-8
9
+ # locale such as LANG=C that tag is US-ASCII, where any non-ASCII byte
10
+ # raises on use. Re-tag everything crossing the boundary as UTF-8.
11
+ module Text
12
+ module_function
13
+
14
+ # Lossy: bytes that are not valid UTF-8 become the replacement
15
+ # character. For log and message display only - never feed the result
16
+ # back into filesystem, cache or protocol identity: scrubbing rewrites
17
+ # raw bytes, which can alias two different names or make a path vanish
18
+ # from a File.exist? probe.
19
+ def utf8(string)
20
+ string.dup.force_encoding(Encoding::UTF_8).scrub
21
+ end
22
+
23
+ # Lossless: every byte survives; only the encoding tag changes. Use for
24
+ # paths and identity-bearing fields. File.* calls and String == then
25
+ # work on the exact bytes git reported; only rendering (regexes over
26
+ # arbitrary content, JSON, terminal output) needs #utf8.
27
+ def utf8_identity(string)
28
+ string.dup.force_encoding(Encoding::UTF_8)
29
+ end
30
+
31
+ # Lossless + strict: retag without scrubbing, then reject bytes
32
+ # that are not valid UTF-8 with JSON::ParserError, so a JSON
33
+ # boundary's existing parse-error handling rejects corrupt input
34
+ # instead of silently accepting a rewritten version of it.
35
+ def utf8_strict(string)
36
+ tagged = utf8_identity(string)
37
+ raise JSON::ParserError, "invalid UTF-8" unless tagged.valid_encoding?
38
+
39
+ tagged
40
+ end
41
+ end
42
+ end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.0"
6
6
  end
data/lib/saboteur.rb CHANGED
@@ -6,11 +6,26 @@ require_relative "saboteur/version"
6
6
  module Saboteur
7
7
  class Error < StandardError; end
8
8
 
9
+ # A phase timeout that --max-duration shortened: budget exhaustion, not a
10
+ # broken suite. The engine turns it into the budget partial-report path
11
+ # instead of a phase error (#296).
12
+ class BudgetPhaseTimeout < Error; end
13
+
9
14
  # Invalid configuration or input files (exit code 2).
10
15
  class ConfigError < Error; end
11
16
 
12
17
  # Files vanished from the working tree while mutants ran (exit code 3).
13
- class CanaryTripped < Error; end
18
+ class CanaryTripped < Error
19
+ # Set by the per-mutant probe (the mutant whose run just ended, #235);
20
+ # nil for a subject-end check, whose "most recent mutant" is only the
21
+ # likely cause and must not outrank a per-mutant report (#285).
22
+ attr_reader :culprit_digest
23
+
24
+ def initialize(message = nil, culprit_digest: nil)
25
+ @culprit_digest = culprit_digest
26
+ super(message)
27
+ end
28
+ end
14
29
 
15
30
  class << self
16
31
  # Set while hook files from config load, so they can call Saboteur.hook.
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.6.0
4
+ version: 0.7.0
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-14 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fiddle
@@ -73,9 +73,13 @@ files:
73
73
  - lib/saboteur/baseline_health.rb
74
74
  - lib/saboteur/cli.rb
75
75
  - lib/saboteur/cli/exit_status.rb
76
+ - lib/saboteur/cli/help_options.rb
77
+ - lib/saboteur/cli/presets.rb
78
+ - lib/saboteur/cli/run_budget_options.rb
76
79
  - lib/saboteur/cli/run_reporting.rb
77
80
  - lib/saboteur/config.rb
78
81
  - lib/saboteur/config_file.rb
82
+ - lib/saboteur/crash_report.rb
79
83
  - lib/saboteur/custom_map.rb
80
84
  - lib/saboteur/doctor.rb
81
85
  - lib/saboteur/engine.rb
@@ -85,19 +89,24 @@ files:
85
89
  - lib/saboteur/engine/canary.rb
86
90
  - lib/saboteur/engine/candidate_selection.rb
87
91
  - lib/saboteur/engine/candidates.rb
92
+ - lib/saboteur/engine/coverage_guard.rb
88
93
  - lib/saboteur/engine/coverage_store.rb
89
94
  - lib/saboteur/engine/data_inputs.rb
90
95
  - lib/saboteur/engine/diagnostics.rb
91
96
  - lib/saboteur/engine/diff_scope.rb
92
97
  - lib/saboteur/engine/distribution.rb
98
+ - lib/saboteur/engine/early_stop_recording.rb
93
99
  - lib/saboteur/engine/result_cache.rb
100
+ - lib/saboteur/engine/run_budget.rb
94
101
  - lib/saboteur/engine/run_fingerprint.rb
95
102
  - lib/saboteur/engine/sandbox_policy.rb
96
103
  - lib/saboteur/engine/scheduler.rb
97
104
  - lib/saboteur/engine/subject_selection.rb
98
105
  - lib/saboteur/engine/suite_files.rb
99
106
  - lib/saboteur/engine/test_guess.rb
107
+ - lib/saboteur/engine/thread_warnings.rb
100
108
  - lib/saboteur/engine/unchanged_syntax.rb
109
+ - lib/saboteur/engine/unscoped_suite_warning.rb
101
110
  - lib/saboteur/engine/verdict_check.rb
102
111
  - lib/saboteur/execution/fork_readiness.rb
103
112
  - lib/saboteur/execution/process_table.rb
@@ -130,11 +139,15 @@ files:
130
139
  - lib/saboteur/mutation_source/endpoint_replacement.rb
131
140
  - lib/saboteur/mutation_source/filter_replacement.rb
132
141
  - lib/saboteur/mutation_source/hazard_guard.rb
142
+ - lib/saboteur/mutation_source/interpolation_removal.rb
133
143
  - lib/saboteur/mutation_source/literal_replacement.rb
144
+ - lib/saboteur/mutation_source/method_swap.rb
134
145
  - lib/saboteur/mutation_source/mutation.rb
146
+ - lib/saboteur/mutation_source/operand_replacement.rb
135
147
  - lib/saboteur/mutation_source/operator_assignment.rb
136
148
  - lib/saboteur/mutation_source/operator_examples.rb
137
149
  - lib/saboteur/mutation_source/operator_registry.rb
150
+ - lib/saboteur/mutation_source/operator_visit.rb
138
151
  - lib/saboteur/mutation_source/pair_removal.rb
139
152
  - lib/saboteur/mutation_source/range_replacement.rb
140
153
  - lib/saboteur/preview.rb
@@ -167,8 +180,10 @@ files:
167
180
  - lib/saboteur/selection/coverage_map.rb
168
181
  - lib/saboteur/selection/git_diff.rb
169
182
  - lib/saboteur/selection/recorder.rb
183
+ - lib/saboteur/selection/untracked_changes.rb
170
184
  - lib/saboteur/subject.rb
171
185
  - lib/saboteur/subject_finder.rb
186
+ - lib/saboteur/text.rb
172
187
  - lib/saboteur/version.rb
173
188
  - lib/saboteur/working_tree.rb
174
189
  homepage: https://github.com/vamsipavanmahesh/saboteur