saboteur 0.5.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 +399 -0
- data/README.md +202 -42
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/comparison.rb +1 -0
- data/lib/saboteur/baseline/evidence.rb +11 -1
- data/lib/saboteur/baseline/run_evidence.rb +1 -1
- 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 +75 -6
- data/lib/saboteur/cli.rb +80 -48
- data/lib/saboteur/config.rb +88 -13
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +64 -2
- 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/candidates.rb +4 -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 +5 -1
- 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 +183 -23
- 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 +175 -96
- 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/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/help.rb +55 -10
- 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 +22 -13
- 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 +38 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +12 -2
- data/lib/saboteur/reporter/agent_commands.rb +49 -16
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/agent_summary.rb +1 -1
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +4 -5
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +19 -3
- data/lib/saboteur/reporter/stryker.rb +19 -8
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +43 -10
- data/lib/saboteur/result.rb +25 -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 +20 -2
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# The --max-duration wall-clock budget (#245). The clock anchors at
|
|
7
|
+
# engine entry, so boot, baselines and the coverage map spend it too;
|
|
8
|
+
# schedulers get an absolute deadline so a spent budget also stops a
|
|
9
|
+
# long subject between mutants.
|
|
10
|
+
module RunBudget
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def start_budget_clock(started)
|
|
14
|
+
@run_started = started
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The whole run is budgeted, baselines included: an oversized
|
|
18
|
+
# baseline spends the budget by itself.
|
|
19
|
+
def budget_exceeded?
|
|
20
|
+
return false unless config["max_duration"]
|
|
21
|
+
|
|
22
|
+
exceeded = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started >= config["max_duration"]
|
|
23
|
+
announce_budget_once if exceeded
|
|
24
|
+
exceeded
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A phase admitted inside the budget still gets only the time left
|
|
28
|
+
# in it: baseline passes and coverage builds cap at the deadline.
|
|
29
|
+
def budget_capped_timeout(timeout)
|
|
30
|
+
deadline = budget_deadline
|
|
31
|
+
return timeout unless deadline
|
|
32
|
+
|
|
33
|
+
(deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).clamp(0.0, timeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Absolute monotonic deadline shared with the schedulers (forked
|
|
37
|
+
# workers inherit it): a subject's remaining mutants stop once it
|
|
38
|
+
# passes, so the overshoot after a spent budget stays under one
|
|
39
|
+
# mutant deadline.
|
|
40
|
+
def budget_deadline
|
|
41
|
+
config["max_duration"] && (@run_started + config["max_duration"])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Boot and baselines can spend the budget by themselves: stop before
|
|
45
|
+
# an expensive cold coverage build and mark every selected subject
|
|
46
|
+
# unrun.
|
|
47
|
+
def abandon_for_budget?(subjects)
|
|
48
|
+
return false unless budget_exceeded?
|
|
49
|
+
|
|
50
|
+
@early_stop = EarlyStop.new(reason: "max_duration", unreached: subjects.map(&:id), truncated: [],
|
|
51
|
+
unverified: [])
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def announce_budget_once
|
|
56
|
+
return if @budget_announced
|
|
57
|
+
|
|
58
|
+
@budget_announced = true
|
|
59
|
+
@diagnostics.inform("--max-duration reached: stopping before the next subject; the report marks it partial")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "json"
|
|
6
|
+
require_relative "../text"
|
|
6
7
|
require_relative "data_inputs"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
@@ -127,7 +128,7 @@ module Saboteur
|
|
|
127
128
|
issues = output_overlap_issues
|
|
128
129
|
entries = inventory(issues).sort_by(&:path).freeze
|
|
129
130
|
issues = issues.uniq.sort
|
|
130
|
-
settings_digest = hash_value(@config.
|
|
131
|
+
settings_digest = hash_value(@config.selection_settings.except(*LOCATION_SETTINGS))
|
|
131
132
|
runtime_digest = hash_value(@runtime_identity)
|
|
132
133
|
digest = hash_value(format: FORMAT_VERSION, files: entries.map(&:to_h),
|
|
133
134
|
settings: settings_digest, runtime: runtime_digest)
|
|
@@ -252,6 +253,9 @@ module Saboteur
|
|
|
252
253
|
# undeclared dependency boundary, not a silently omitted source tree.
|
|
253
254
|
def project_files(issues, directory = @root)
|
|
254
255
|
Dir.children(directory).sort.flat_map do |name|
|
|
256
|
+
# Child names arrive locale-tagged; retag losslessly before
|
|
257
|
+
# joining with the UTF-8 root (#231).
|
|
258
|
+
name = Text.utf8_identity(name)
|
|
255
259
|
path = File.join(directory, name)
|
|
256
260
|
next [] if excluded?(path, name)
|
|
257
261
|
|
|
@@ -16,7 +16,10 @@ module Saboteur
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# The third C1 layer (#152): each mutant child makes the project tree
|
|
19
|
-
# read-only except tmp
|
|
19
|
+
# read-only except tmp/ and the system temp dir - the cache dir stays
|
|
20
|
+
# read-only too, so a mutant cannot delete the baseline, the reports
|
|
21
|
+
# or other children's logs; its own log arrives as an inherited open
|
|
22
|
+
# descriptor (#234). The
|
|
20
23
|
# claim needs Landlock ABI 3 (Linux 6.2+): earlier ABIs cannot restrict
|
|
21
24
|
# truncate(), so the run degrades honestly - children run with the
|
|
22
25
|
# guard and canary only, noted once - unless c.sandbox = "strict",
|
|
@@ -28,14 +31,15 @@ module Saboteur
|
|
|
28
31
|
return restrict_hook if abi >= 3
|
|
29
32
|
|
|
30
33
|
if @config["sandbox"] == "strict"
|
|
31
|
-
raise Error, "sandbox is strict but #{sandbox_gap(abi)}; refusing to run without the promised " \
|
|
34
|
+
raise Error, "sandbox is strict but #{self.class.sandbox_gap(abi)}; refusing to run without the promised " \
|
|
32
35
|
"sandbox - set c.sandbox = true to run degraded, or false to opt out"
|
|
33
36
|
end
|
|
34
37
|
# Once, through the report's warnings only: warn + inform printed the
|
|
35
38
|
# same line twice (D's review, #178).
|
|
36
39
|
unless @sandbox_note_given
|
|
37
40
|
@sandbox_note_given = true
|
|
38
|
-
@diagnostics.warn("sandbox is on but #{sandbox_gap(abi)}; children run with the hazard guard
|
|
41
|
+
@diagnostics.warn("sandbox is on but #{self.class.sandbox_gap(abi)}; children run with the hazard guard " \
|
|
42
|
+
"and canary only")
|
|
39
43
|
end
|
|
40
44
|
nil
|
|
41
45
|
end
|
|
@@ -49,7 +53,7 @@ module Saboteur
|
|
|
49
53
|
"unsandboxed - set c.sandbox = false to proceed without it"
|
|
50
54
|
end
|
|
51
55
|
|
|
52
|
-
def sandbox_gap(abi)
|
|
56
|
+
def self.sandbox_gap(abi)
|
|
53
57
|
return "this kernel lacks Landlock (needs Linux 6.2+)" if abi.zero?
|
|
54
58
|
|
|
55
59
|
"this kernel's Landlock ABI is #{abi} but ABI 3 (Linux 6.2+) is needed to restrict truncate()"
|
|
@@ -62,20 +66,23 @@ module Saboteur
|
|
|
62
66
|
|
|
63
67
|
# c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
|
|
64
68
|
# ...): without a grant, writes there fail with EACCES in the child.
|
|
69
|
+
# Only the user grants are created: the project tmp is granted when it
|
|
70
|
+
# already exists, never made for it (#236).
|
|
65
71
|
def sandbox_writable_dirs
|
|
66
72
|
user_dirs = @config["sandbox_writable"].map { |dir| File.expand_path(dir, @root) }
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
user_dirs.each { |dir| FileUtils.mkdir_p(dir) }
|
|
74
|
+
tmp = File.join(@root, "tmp")
|
|
75
|
+
dirs = Dir.exist?(tmp) ? [tmp] + user_dirs : user_dirs
|
|
69
76
|
real = (dirs.map { |dir| File.realpath(dir) } + [File.realpath(Dir.tmpdir)]).uniq
|
|
70
77
|
check_sandbox_write_set(real, user_dirs.map { |dir| File.realpath(dir) })
|
|
71
78
|
real
|
|
72
79
|
end
|
|
73
80
|
|
|
74
|
-
# The effective write set - every grant realpath'd,
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
81
|
+
# The effective write set - every grant realpath'd, the project tmp
|
|
82
|
+
# and the system temp dir included - must not contain the project
|
|
83
|
+
# root: a tmp symlink or a checkout inside the system temp dir would
|
|
84
|
+
# otherwise leave the whole tree writable without a word.
|
|
85
|
+
# c.sandbox = "strict" refuses: strict guards the effective
|
|
79
86
|
# policy, not only the kernel ABI. A user grant containing another
|
|
80
87
|
# grant only makes the narrower one redundant, so that stays a warning.
|
|
81
88
|
def check_sandbox_write_set(all_dirs, user_dirs)
|
|
@@ -27,8 +27,9 @@ module Saboteur
|
|
|
27
27
|
tests.sort_by.with_index { |id, index| [-killers.fetch(id, 0), index] }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil)
|
|
30
|
+
def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil, deadline: nil)
|
|
31
31
|
@config = config
|
|
32
|
+
@deadline = deadline
|
|
32
33
|
@runner = runner
|
|
33
34
|
@map = map
|
|
34
35
|
@reevaluator = reevaluator
|
|
@@ -73,23 +74,53 @@ module Saboteur
|
|
|
73
74
|
|
|
74
75
|
# Verdicts that did not hold up are warned about once per subject;
|
|
75
76
|
# only checked verdicts are stored for --incremental.
|
|
77
|
+
# A spent budget admits no check: forking the unchanged run with no
|
|
78
|
+
# time left would turn genuine kills into fabricated errors and cache
|
|
79
|
+
# them. Fresh kills stand unverified instead - outside the score,
|
|
80
|
+
# never cached - and the distribution marks the subject so the run
|
|
81
|
+
# reports budget-partial.
|
|
76
82
|
def checked(plan, results)
|
|
83
|
+
return results.map { |result| unverifiable(result) } if budget_spent?
|
|
84
|
+
|
|
77
85
|
subject = plan.subject
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
eval_prefix: eval_prefix(plan))
|
|
81
|
-
.tap { |outcome| note_leftovers(subject, "verdict check", outcome) }
|
|
82
|
-
end
|
|
83
|
-
check = VerdictCheck.new(run_unchanged: run_unchanged, baseline_timeout: @config["baseline_timeout"])
|
|
86
|
+
check = VerdictCheck.new(run_unchanged: unchanged_runner(subject, plan),
|
|
87
|
+
baseline_timeout: @config["baseline_timeout"])
|
|
84
88
|
final = check.call(results)
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
# Unverified verdicts are never cached: a budget-cut check proves
|
|
90
|
+
# nothing, and a stored copy would read as confirmed next run.
|
|
91
|
+
final.reject { |result| result.reused? || result.status == :unverified }.each { |r| @cache&.record(r) }
|
|
92
|
+
overturned = final.zip(results).count { |after, before| after.status == :error && !after.equal?(before) }
|
|
87
93
|
if overturned.positive?
|
|
88
94
|
@diagnostics.warn("#{subject.id}: #{overturned} verdict(s) failed with the method unchanged")
|
|
89
95
|
end
|
|
90
96
|
final
|
|
91
97
|
end
|
|
92
98
|
|
|
99
|
+
# Admission is per sequence, not just at checked() entry: a check
|
|
100
|
+
# admitted with time left can hit its capped timeout mid-check, and a
|
|
101
|
+
# later sequence can arrive after the budget is spent. Neither may
|
|
102
|
+
# fork with no time left, and neither outcome is a rejected kill -
|
|
103
|
+
# the check never got its answer (#296).
|
|
104
|
+
def unchanged_runner(subject, plan)
|
|
105
|
+
lambda do |tests, timeout|
|
|
106
|
+
if budget_spent?
|
|
107
|
+
Execution::Runner::Outcome.new(status: :unverified, runtime: 0.0,
|
|
108
|
+
message: "the --max-duration budget is spent")
|
|
109
|
+
else
|
|
110
|
+
capped = budget_capped(timeout)
|
|
111
|
+
outcome = @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: capped,
|
|
112
|
+
eval_prefix: eval_prefix(plan))
|
|
113
|
+
note_leftovers(subject, "verdict check", outcome)
|
|
114
|
+
if outcome.status == :timeout && capped < timeout
|
|
115
|
+
Execution::Runner::Outcome.new(status: :unverified, runtime: outcome.runtime,
|
|
116
|
+
message: "the --max-duration budget cut the verdict check short")
|
|
117
|
+
else
|
|
118
|
+
outcome
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
93
124
|
# When every verdict can be reused, nothing forks: not even the neutral
|
|
94
125
|
# check. Returns the results, or nil at the first one that must run.
|
|
95
126
|
def all_reused(plan, candidates)
|
|
@@ -100,10 +131,16 @@ module Saboteur
|
|
|
100
131
|
end
|
|
101
132
|
end
|
|
102
133
|
|
|
134
|
+
# The budget deadline is checked between mutants, so a spent
|
|
135
|
+
# --max-duration stops inside a long subject too; completed results
|
|
136
|
+
# are kept and the distribution marks what follows as unreached.
|
|
103
137
|
def run_candidates(candidates, &run_one)
|
|
104
138
|
coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
|
|
105
|
-
|
|
106
|
-
|
|
139
|
+
(coarse + fine).each_with_object([]) do |(mutation, text), results|
|
|
140
|
+
break results if @deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
|
|
141
|
+
|
|
142
|
+
results << run_one.call(mutation, text)
|
|
143
|
+
end
|
|
107
144
|
end
|
|
108
145
|
|
|
109
146
|
# A stored verdict that still holds, as a result; nil when it must run.
|
|
@@ -206,19 +243,78 @@ module Saboteur
|
|
|
206
243
|
# baseline_timeout. If the deadline from that real runtime is longer,
|
|
207
244
|
# the mutant runs again with it, and later mutants of the subject get
|
|
208
245
|
# the measured slowdown. A timeout that remains is the mutant's own.
|
|
246
|
+
# A --max-duration deadline caps every admission the scheduler makes:
|
|
247
|
+
# verdict checks and timeout calibrations get only the time left.
|
|
248
|
+
def budget_capped(timeout)
|
|
249
|
+
return timeout unless @deadline
|
|
250
|
+
|
|
251
|
+
remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
252
|
+
remaining.clamp(0.0, timeout)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def budget_spent?
|
|
256
|
+
@deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A fresh kill whose check the budget skipped proves nothing yet: it
|
|
260
|
+
# becomes unverified, outside the score, and is never cached.
|
|
261
|
+
def unverifiable(result)
|
|
262
|
+
return result if result.reused? || result.status != :killed
|
|
263
|
+
|
|
264
|
+
result.as_unverified("verdict check skipped: the --max-duration budget is spent")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A timeout the budget keeps the calibration from judging proves
|
|
268
|
+
# nothing either: no zero-time forks, and a retry cut short does not
|
|
269
|
+
# count as detected.
|
|
209
270
|
def calibrate(plan, mutation, text, tests, timeout)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
271
|
+
if budget_spent?
|
|
272
|
+
return unverified_outcome(timeout, "timeout calibration skipped: the --max-duration budget is spent")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
unchanged, shortened = unchanged_timing(plan, tests)
|
|
276
|
+
if unchanged.status == :timeout && shortened
|
|
277
|
+
return unverified_outcome(unchanged.runtime, "the --max-duration budget cut the timeout calibration short")
|
|
278
|
+
end
|
|
213
279
|
return unchanged_failed(unchanged) unless unchanged.status == :survived
|
|
214
280
|
|
|
281
|
+
retry_longer(plan, mutation, text, tests, [timeout, unchanged.runtime])
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The unchanged method under the budget-capped baseline timeout, and
|
|
285
|
+
# whether the cap shortened it.
|
|
286
|
+
def unchanged_timing(plan, tests)
|
|
287
|
+
capped = budget_capped(@config["baseline_timeout"])
|
|
288
|
+
outcome = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
|
|
289
|
+
timeout: capped, eval_prefix: eval_prefix(plan))
|
|
290
|
+
note_leftovers(plan.subject, "timeout check", outcome)
|
|
291
|
+
[outcome, capped < @config["baseline_timeout"]]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def retry_longer(plan, mutation, text, tests, limit)
|
|
295
|
+
timeout, unchanged_runtime = limit
|
|
215
296
|
recorded = @map.recorded_runtime(tests)
|
|
216
|
-
plan.slowdown = [plan.slowdown,
|
|
217
|
-
longer = deadline(
|
|
297
|
+
plan.slowdown = [plan.slowdown, unchanged_runtime / recorded].max if recorded&.positive?
|
|
298
|
+
longer = deadline(unchanged_runtime)
|
|
218
299
|
return Execution::Runner::Outcome.new(status: :timeout, runtime: timeout) if longer <= timeout
|
|
300
|
+
# The unchanged timing may have used the final allowance: no
|
|
301
|
+
# zero-time retry fork (#296).
|
|
302
|
+
if budget_spent?
|
|
303
|
+
return unverified_outcome(timeout, "the --max-duration budget is spent before the mutant retry")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
retry_timeout = budget_capped(longer)
|
|
307
|
+
retried = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: retry_timeout,
|
|
308
|
+
mutation: mutation, eval_prefix: eval_prefix(plan))
|
|
309
|
+
return retried unless retried.status == :timeout && retry_timeout < longer
|
|
310
|
+
|
|
311
|
+
unverified_outcome(retried.runtime, "the --max-duration budget cut the mutant retry short")
|
|
312
|
+
end
|
|
219
313
|
|
|
220
|
-
|
|
221
|
-
|
|
314
|
+
# A budget-cut calibration proves nothing about the mutant: outside
|
|
315
|
+
# the score, and never cached.
|
|
316
|
+
def unverified_outcome(runtime, message)
|
|
317
|
+
Execution::Runner::Outcome.new(status: :unverified, runtime: runtime, message: message)
|
|
222
318
|
end
|
|
223
319
|
|
|
224
320
|
def unchanged_failed(unchanged)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "../text"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
class Engine
|
|
6
8
|
# The subjects to mutate: every def in the included files, narrowed by
|
|
@@ -13,11 +15,12 @@ module Saboteur
|
|
|
13
15
|
@config = config
|
|
14
16
|
@diagnostics = diagnostics
|
|
15
17
|
@root = File.realpath(config.root)
|
|
16
|
-
@changed_lines = nil
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def select(files)
|
|
21
|
+
@files = files
|
|
20
22
|
subjects = find(files)
|
|
23
|
+
@discovered = subjects
|
|
21
24
|
subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
|
|
22
25
|
subjects = by_change(subjects, @config["since"]) if @config["since"]
|
|
23
26
|
subjects
|
|
@@ -28,6 +31,22 @@ module Saboteur
|
|
|
28
31
|
id == pattern || %w[# . ::].any? { |separator| id.start_with?("#{pattern}#{separator}") }
|
|
29
32
|
end
|
|
30
33
|
|
|
34
|
+
# The files (absolute paths) holding a subject matching any pattern,
|
|
35
|
+
# returned relative to root. --subject inference guesses specs for
|
|
36
|
+
# exactly these, so one unrelated spec-less file cannot block a
|
|
37
|
+
# one-method run.
|
|
38
|
+
def self.files_matching(root, files, patterns)
|
|
39
|
+
finder = SubjectFinder.new
|
|
40
|
+
prefix = "#{root}/"
|
|
41
|
+
files.filter_map do |file|
|
|
42
|
+
parsed = finder.call(file)
|
|
43
|
+
matching = parsed.subjects.any? do |subject|
|
|
44
|
+
patterns.any? { |pattern| match?(subject.id, pattern) }
|
|
45
|
+
end
|
|
46
|
+
file.delete_prefix(prefix) if matching
|
|
47
|
+
end.sort
|
|
48
|
+
end
|
|
49
|
+
|
|
31
50
|
private
|
|
32
51
|
|
|
33
52
|
def find(files)
|
|
@@ -54,14 +73,74 @@ module Saboteur
|
|
|
54
73
|
end
|
|
55
74
|
|
|
56
75
|
def by_change(subjects, ref)
|
|
57
|
-
@changed_lines = Selection::GitDiff.changed_lines(ref, root: @root
|
|
76
|
+
@changed_lines = Selection::GitDiff.changed_lines(ref, root: @root, include: @files.to_set,
|
|
77
|
+
diagnostics: @diagnostics)
|
|
58
78
|
.transform_keys { |file| Selection::CoverageMap.canonical(file) }
|
|
59
|
-
changed = subjects
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
changed = changed_subjects(subjects)
|
|
80
|
+
warn_changed_files_without_subjects
|
|
81
|
+
warn_excluded_changed_files
|
|
62
82
|
drop_unchanged_syntax(changed, ref)
|
|
63
83
|
end
|
|
64
84
|
|
|
85
|
+
# subject.file carries the locale tag under a non-UTF-8 locale
|
|
86
|
+
# while the diff keys are UTF-8; retag losslessly before lookup so
|
|
87
|
+
# a non-ASCII path matches by its exact bytes (#231).
|
|
88
|
+
def changed_subjects(subjects)
|
|
89
|
+
subjects.select do |subject|
|
|
90
|
+
key = Selection::CoverageMap.canonical(Text.utf8_identity(subject.file))
|
|
91
|
+
lines = @changed_lines[key]
|
|
92
|
+
lines && subject.overlaps_lines?(lines)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A changed Ruby file in scope whose changed lines cover no subject
|
|
97
|
+
# silently contributes nothing to the run (#232): warn, or a typo, an
|
|
98
|
+
# unparseable file or a selection bug passes as a clean selection.
|
|
99
|
+
# Comment-only changes stay quiet: their file matched subjects before
|
|
100
|
+
# drop_unchanged_syntax pruned them, and that prune is reported. A
|
|
101
|
+
# diff touching no included Ruby file is a legitimate empty scope.
|
|
102
|
+
def warn_changed_files_without_subjects
|
|
103
|
+
canonical = ->(file) { Selection::CoverageMap.canonical(Text.utf8_identity(file)) }
|
|
104
|
+
included = @files.map(&canonical)
|
|
105
|
+
matched = changed_subjects(@discovered).map { |subject| canonical.call(subject.file) }
|
|
106
|
+
unmatched = (@changed_lines.keys.select { |file| file.end_with?(".rb") } & included) - matched
|
|
107
|
+
return if unmatched.empty?
|
|
108
|
+
|
|
109
|
+
names = unmatched.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
|
|
110
|
+
more = unmatched.size > 5 ? ", ..." : ""
|
|
111
|
+
@diagnostics.warn("--since: #{unmatched.size} changed Ruby file(s) have no subjects covering " \
|
|
112
|
+
"the changed lines, so nothing is mutated from them: #{names}#{more}. The " \
|
|
113
|
+
"file may not parse, or every change may sit outside a method.")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A changed Ruby file that an include pattern covers but c.exclude
|
|
117
|
+
# removes yields zero candidates with no other sign - this repo's own
|
|
118
|
+
# config excludes lib/saboteur/execution/**, so harness changes once
|
|
119
|
+
# dogfooded as a clean empty scope (#282). Names at most five.
|
|
120
|
+
def warn_excluded_changed_files
|
|
121
|
+
untracked = Selection::UntrackedChanges.paths(@root)
|
|
122
|
+
hits = excluded_territory & (@changed_lines.keys + untracked)
|
|
123
|
+
return if hits.empty?
|
|
124
|
+
|
|
125
|
+
names = hits.sort.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
|
|
126
|
+
more = hits.size > 5 ? ", ..." : ""
|
|
127
|
+
@diagnostics.warn("--since: #{hits.size} changed Ruby file(s) are covered by c.include but removed by " \
|
|
128
|
+
"c.exclude, so nothing is mutated from them: #{names}#{more}. Narrow or remove the " \
|
|
129
|
+
"c.exclude entry, or pass the file as a positional source operand (configured " \
|
|
130
|
+
"excludes are then ignored).")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Ruby files an include pattern and an exclude pattern both match,
|
|
134
|
+
# canonicalized the way the diff map keys are.
|
|
135
|
+
def excluded_territory
|
|
136
|
+
canonical = lambda do |file|
|
|
137
|
+
Selection::CoverageMap.canonical(File.expand_path(Text.utf8_identity(file), @config.root))
|
|
138
|
+
end
|
|
139
|
+
territory = @config["include"].flat_map { |pattern| Dir.glob(pattern, base: @config.root) }
|
|
140
|
+
excluded = @config["exclude"].flat_map { |pattern| Dir.glob(pattern, base: @config.root) }
|
|
141
|
+
(territory & excluded).select { |file| file.end_with?(".rb") }.map(&canonical)
|
|
142
|
+
end
|
|
143
|
+
|
|
65
144
|
# A method whose only changes are comments, blank lines or spacing has
|
|
66
145
|
# nothing new to test.
|
|
67
146
|
def drop_unchanged_syntax(subjects, ref)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "shellwords"
|
|
6
|
+
require_relative "../text"
|
|
6
7
|
require_relative "data_inputs"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
@@ -56,7 +57,8 @@ module Saboteur
|
|
|
56
57
|
# `--require name` resolves through RSpec's load path: spec/, lib/ and
|
|
57
58
|
# any -I directory.
|
|
58
59
|
def required_by_options
|
|
59
|
-
tokens = option_files.flat_map { |file| Shellwords.split(File.read(file)) } +
|
|
60
|
+
tokens = option_files.flat_map { |file| Shellwords.split(Text.utf8_identity(File.read(file))) } +
|
|
61
|
+
@config["rspec_args"]
|
|
60
62
|
load_path = LOAD_PATH + option_values(tokens, "-I", "--include")
|
|
61
63
|
option_values(tokens, *REQUIRE_FLAGS).filter_map { |name| resolve(name, load_path) }
|
|
62
64
|
end
|