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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +367 -0
- data/README.md +166 -40
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +49 -4
- data/lib/saboteur/cli/help_options.rb +16 -0
- data/lib/saboteur/cli/presets.rb +36 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +74 -5
- data/lib/saboteur/cli.rb +67 -46
- data/lib/saboteur/config.rb +66 -11
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +40 -1
- data/lib/saboteur/engine/baseline_guard.rb +79 -5
- data/lib/saboteur/engine/boot.rb +12 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +9 -1
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +48 -6
- data/lib/saboteur/engine/diagnostics.rb +23 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +124 -8
- data/lib/saboteur/engine/early_stop_recording.rb +46 -0
- data/lib/saboteur/engine/result_cache.rb +2 -1
- data/lib/saboteur/engine/run_budget.rb +63 -0
- data/lib/saboteur/engine/run_fingerprint.rb +4 -0
- data/lib/saboteur/engine/sandbox_policy.rb +18 -11
- data/lib/saboteur/engine/scheduler.rb +114 -18
- data/lib/saboteur/engine/subject_selection.rb +84 -5
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +145 -22
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +166 -95
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/runner.rb +179 -17
- data/lib/saboteur/execution/sandbox.rb +6 -1
- data/lib/saboteur/execution/supervisor.rb +18 -3
- data/lib/saboteur/execution/worker_pool.rb +87 -7
- data/lib/saboteur/help.rb +55 -11
- data/lib/saboteur/ignore_list.rb +11 -1
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +40 -4
- data/lib/saboteur/mutation_source/builtin.rb +20 -11
- data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/method_swap.rb +45 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +2 -1
- data/lib/saboteur/reporter/agent_commands.rb +38 -13
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +13 -4
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +34 -10
- data/lib/saboteur/result.rb +22 -7
- data/lib/saboteur/selection/coverage_map.rb +2 -1
- data/lib/saboteur/selection/git_diff.rb +15 -5
- data/lib/saboteur/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +11 -0
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +16 -1
- 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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
119
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
"covers only
|
|
235
|
-
stop.unreached
|
|
236
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
data/lib/saboteur/result.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
105
|
-
# the
|
|
106
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
data/lib/saboteur/subject.rb
CHANGED
|
@@ -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
|
data/lib/saboteur/version.rb
CHANGED
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
|
|
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.
|
|
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-
|
|
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
|