saboteur 0.12.1 → 0.12.3
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 +86 -0
- data/README.md +18 -3
- data/lib/saboteur/baseline/command.rb +6 -1
- data/lib/saboteur/baseline/comparison.rb +8 -0
- data/lib/saboteur/baseline/completeness.rb +14 -4
- data/lib/saboteur/baseline/evidence.rb +8 -1
- data/lib/saboteur/baseline/run_evidence.rb +10 -1
- data/lib/saboteur/baseline/workflow.rb +17 -5
- data/lib/saboteur/cli/color_option.rb +26 -0
- data/lib/saboteur/cli/doctor_command.rb +1 -0
- data/lib/saboteur/cli/exit_status.rb +4 -3
- data/lib/saboteur/cli/explain_command.rb +2 -1
- data/lib/saboteur/cli/failure_output.rb +13 -2
- data/lib/saboteur/cli/help_command.rb +5 -0
- data/lib/saboteur/cli/history_command.rb +2 -0
- data/lib/saboteur/cli/output_options.rb +1 -5
- data/lib/saboteur/cli/run_command.rb +1 -1
- data/lib/saboteur/cli/run_reporting.rb +1 -9
- data/lib/saboteur/cli/run_validation.rb +0 -1
- data/lib/saboteur/cli/survivors_command.rb +10 -6
- data/lib/saboteur/cli/whole_suite_map_lookup.rb +33 -0
- data/lib/saboteur/cli.rb +6 -2
- data/lib/saboteur/engine/coverage_store.rb +7 -4
- data/lib/saboteur/engine/diagnostics.rb +7 -0
- data/lib/saboteur/engine/pipelines.rb +9 -2
- data/lib/saboteur/engine/result_cache.rb +8 -5
- data/lib/saboteur/engine/scheduler.rb +3 -2
- data/lib/saboteur/engine/survivor_confirmation.rb +75 -11
- data/lib/saboteur/engine/test_guess.rb +2 -1
- data/lib/saboteur/engine.rb +34 -16
- data/lib/saboteur/execution/runner.rb +14 -4
- data/lib/saboteur/execution/site_probe.rb +161 -0
- data/lib/saboteur/gates.rb +1 -1
- data/lib/saboteur/integration/rspec.rb +30 -0
- data/lib/saboteur/reporter/default_findings.rb +1 -1
- data/lib/saboteur/reporter/findings.rb +2 -1
- data/lib/saboteur/reporter/gap_kinds.rb +18 -0
- data/lib/saboteur/reporter/next_steps.rb +3 -1
- data/lib/saboteur/reporter/review_comments.rb +4 -1
- data/lib/saboteur/reporter/score_table.rb +6 -11
- data/lib/saboteur/reporter/stryker.rb +12 -2
- data/lib/saboteur/reporter/survivor_classification.rb +53 -6
- data/lib/saboteur/reporter/survivor_hints.rb +13 -9
- data/lib/saboteur/reporter/survivor_list.rb +6 -6
- data/lib/saboteur/reporter/terminal.rb +4 -2
- data/lib/saboteur/reporter/unconfirmed_section.rb +44 -0
- data/lib/saboteur/result.rb +46 -4
- data/lib/saboteur/selection/coverage_map.rb +11 -1
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +5 -0
- metadata +5 -1
|
@@ -18,7 +18,10 @@ module Saboteur
|
|
|
18
18
|
CACHE_INPUTS = %w[integration require rspec_args rails tests coverage_recorder].freeze
|
|
19
19
|
# Names the freshest whole-suite map build; read as an untrusted hint:
|
|
20
20
|
# only a bare 64-hex key joins the cache dir (#427).
|
|
21
|
-
|
|
21
|
+
# Renamed from "whole-suite-map" in 0.12.2: older versions also wrote
|
|
22
|
+
# the pointer for maps built from inferred specs, which are not whole
|
|
23
|
+
# suites, so their pointers are never read (#1286).
|
|
24
|
+
WHOLE_SUITE_POINTER = "whole-suite-map-v2"
|
|
22
25
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
23
26
|
LOG = "coverage-build.log"
|
|
24
27
|
# RSpec rewrites this on every run (example_status_persistence_file_path),
|
|
@@ -65,7 +68,7 @@ module Saboteur
|
|
|
65
68
|
return serve_cached(path, cached, files, preloaded) if cached
|
|
66
69
|
|
|
67
70
|
note = miss_note(files)
|
|
68
|
-
@diagnostics.
|
|
71
|
+
@diagnostics.notice(note.message(scoped: !@config["tests"].empty?)) if @config["cache"]
|
|
69
72
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
73
|
map = fresh_map(files, note, preloaded)
|
|
71
74
|
build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
@@ -237,7 +240,7 @@ module Saboteur
|
|
|
237
240
|
return build_parts(files, parts) if parts.size > 1
|
|
238
241
|
|
|
239
242
|
scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
|
|
240
|
-
@diagnostics.
|
|
243
|
+
@diagnostics.notice("building coverage map (runs #{scope} once)")
|
|
241
244
|
# The suite child forked next inherits this heap; pack it first, to
|
|
242
245
|
# reduce later copying of shared pages.
|
|
243
246
|
Execution::ForkReadiness.prepare_heap
|
|
@@ -267,7 +270,7 @@ module Saboteur
|
|
|
267
270
|
total, counts = usage_checked { check_parts_cover_suite(parts) }
|
|
268
271
|
saved = saved_parts(files, parts.size, counts)
|
|
269
272
|
todo = saved.each_index.select { |index| saved[index].nil? }
|
|
270
|
-
@diagnostics.
|
|
273
|
+
@diagnostics.notice(parts_note(parts.size, todo.size))
|
|
271
274
|
built = todo.empty? ? [] : run_parts(files, parts, todo)
|
|
272
275
|
todo.zip(built) { |index, map| saved[index] = map }
|
|
273
276
|
map = combine_saved(saved, total, files, parts.size)
|
|
@@ -187,6 +187,13 @@ module Saboteur
|
|
|
187
187
|
@err.puts("saboteur: #{Text.display_safe_lines(message)}")
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
+
# First-run explanations (#1264): the --quiet contract is findings,
|
|
191
|
+
# score and verdict, so these stay off stderr under it and print
|
|
192
|
+
# exactly as before otherwise.
|
|
193
|
+
def notice(message)
|
|
194
|
+
inform(message) unless @quiet
|
|
195
|
+
end
|
|
196
|
+
|
|
190
197
|
# Chatter the default view folds into the quiet-start lines;
|
|
191
198
|
# --verbose prints it byte-for-byte as before.
|
|
192
199
|
def detail(message)
|
|
@@ -104,9 +104,16 @@ module Saboteur
|
|
|
104
104
|
# "Full run" for ignore staleness (#1206): no --since diff, no --subject
|
|
105
105
|
# filter, and no narrowed include (explicit file arguments replace the
|
|
106
106
|
# configured include) - only then was every in-scope subject examined, so
|
|
107
|
-
# an entry whose subject never appears really points at gone code.
|
|
107
|
+
# an entry whose subject never appears really points at gone code. The
|
|
108
|
+
# include nobody set counts as unscoped whether it is the generic
|
|
109
|
+
# default or the Rails one a config-less app gets (#1255).
|
|
108
110
|
def unscoped_full_run?
|
|
109
|
-
!config["since"] && config["subjects"].empty? &&
|
|
111
|
+
!config["since"] && config["subjects"].empty? && default_include?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def default_include?
|
|
115
|
+
config["include"] == Config::DEFAULTS["include"] ||
|
|
116
|
+
(config["include"] == Config::RAILS_INCLUDE && !config.explicit?("include"))
|
|
110
117
|
end
|
|
111
118
|
|
|
112
119
|
def external_command? = !config["test_command"].to_s.empty?
|
|
@@ -26,7 +26,9 @@ module Saboteur
|
|
|
26
26
|
# every source file is watched instead: reuse turns conservative, never
|
|
27
27
|
# stale.
|
|
28
28
|
class ResultCache
|
|
29
|
-
|
|
29
|
+
# 2: entries carry value_differed (#1290); a version 1 file is dropped so
|
|
30
|
+
# no cached survivor reads as "never differed" without having been observed.
|
|
31
|
+
FORMAT = 2
|
|
30
32
|
MAX_BYTES = 64 * 1024 * 1024
|
|
31
33
|
STORED = %i[killed survived timeout].freeze
|
|
32
34
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
@@ -62,13 +64,13 @@ module Saboteur
|
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
# The stored verdict as a hash (status, reason, killed_by, kill_cause,
|
|
65
|
-
# runtime), or nil when it may no longer hold.
|
|
67
|
+
# runtime, value_differed), or nil when it may no longer hold.
|
|
66
68
|
def lookup(subject, mutation, tests)
|
|
67
69
|
entry = @entries[key(subject, mutation)]
|
|
68
70
|
return nil unless entry && entry["fingerprint"] == fingerprint(subject, tests)
|
|
69
71
|
|
|
70
72
|
{ status: entry["status"].to_sym, reason: entry["reason"], killed_by: entry["killed_by"],
|
|
71
|
-
kill_cause: entry["kill_cause"], runtime: entry["runtime"] }
|
|
73
|
+
kill_cause: entry["kill_cause"], runtime: entry["runtime"], value_differed: entry["value_differed"] }
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
def record(result)
|
|
@@ -77,7 +79,7 @@ module Saboteur
|
|
|
77
79
|
@entries[key(result.subject, result.mutation)] = {
|
|
78
80
|
"fingerprint" => fingerprint(result.subject, result.covered_by), "status" => result.status.to_s,
|
|
79
81
|
"reason" => result.reason, "killed_by" => result.killed_by, "kill_cause" => result.kill_cause,
|
|
80
|
-
"runtime" => result.runtime
|
|
82
|
+
"runtime" => result.runtime, "value_differed" => result.value_differed
|
|
81
83
|
}
|
|
82
84
|
checkpoint if @clock.call - @last_saved >= @save_interval
|
|
83
85
|
end
|
|
@@ -154,7 +156,8 @@ module Saboteur
|
|
|
154
156
|
def typed?(entry)
|
|
155
157
|
STORED.map(&:to_s).include?(entry["status"]) && (entry["runtime"].nil? || entry["runtime"].is_a?(Numeric)) &&
|
|
156
158
|
%w[reason kill_cause].all? { |key| entry[key].nil? || entry[key].is_a?(String) } &&
|
|
157
|
-
entry["killed_by"].is_a?(Array) && entry["killed_by"].all?(String)
|
|
159
|
+
entry["killed_by"].is_a?(Array) && entry["killed_by"].all?(String) &&
|
|
160
|
+
[nil, true, false].include?(entry["value_differed"])
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
def key(subject, mutation)
|
|
@@ -235,7 +235,8 @@ module Saboteur
|
|
|
235
235
|
|
|
236
236
|
MutantResult.new(subject: plan.subject, mutation: mutation, status: hit[:status], mutated_source: text,
|
|
237
237
|
reason: hit[:reason], runtime: hit[:runtime], killed_by: hit[:killed_by],
|
|
238
|
-
covered_by: tests, kill_cause: hit[:kill_cause], reused: true
|
|
238
|
+
covered_by: tests, kill_cause: hit[:kill_cause], reused: true,
|
|
239
|
+
value_differed: hit[:value_differed])
|
|
239
240
|
end
|
|
240
241
|
|
|
241
242
|
def execute(plan, mutation, text)
|
|
@@ -247,7 +248,7 @@ module Saboteur
|
|
|
247
248
|
result = MutantResult.new(subject: plan.subject, mutation: mutation, status: outcome.status,
|
|
248
249
|
mutated_source: text, reason: outcome.message, runtime: outcome.runtime,
|
|
249
250
|
killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause,
|
|
250
|
-
executed: outcome.executed_ids)
|
|
251
|
+
executed: outcome.executed_ids, value_differed: outcome.value_differed)
|
|
251
252
|
outcome.failed_ids.each { |id| plan.killers[id] += 1 }
|
|
252
253
|
note_leftovers(plan.subject, "#{mutation.operator} #{result.digest}", outcome)
|
|
253
254
|
@diagnostics.progress(result)
|
|
@@ -15,13 +15,14 @@ module Saboteur
|
|
|
15
15
|
# confirmation_budget caps the whole pass.
|
|
16
16
|
MAX_SPECS_PER_SURVIVOR = 8
|
|
17
17
|
|
|
18
|
-
def initialize(config:, map:, scheduler:, diagnostics:, budget_exceeded:, cache: nil)
|
|
18
|
+
def initialize(config:, map:, scheduler:, diagnostics:, budget_exceeded:, cache: nil, load_specs: ->(_files) {})
|
|
19
19
|
@config = config
|
|
20
20
|
@map = map
|
|
21
21
|
@scheduler = scheduler
|
|
22
22
|
@diagnostics = diagnostics
|
|
23
23
|
@budget_exceeded = budget_exceeded
|
|
24
24
|
@cache = cache
|
|
25
|
+
@load_specs = load_specs
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def call(results, ignore_list:, early_stop: nil)
|
|
@@ -33,19 +34,60 @@ module Saboteur
|
|
|
33
34
|
"stopped early (#{early_stop.reason})")
|
|
34
35
|
end
|
|
35
36
|
@remaining = @config["confirmation_budget"]
|
|
37
|
+
@map_silent = 0
|
|
36
38
|
results.map { |result| survivors.include?(result) ? confirm(result) : result }
|
|
37
39
|
.tap { |confirmed| summarize(confirmed) }
|
|
40
|
+
.tap { warn_map_silent }
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
private
|
|
41
44
|
|
|
45
|
+
# A crash while confirming one survivor downgrades it to unconfirmed
|
|
46
|
+
# with one warning; it never ends the run (#1140).
|
|
42
47
|
def confirm(result)
|
|
48
|
+
confirm_checked(result)
|
|
49
|
+
rescue RefusedSpecPath => e
|
|
50
|
+
warn_refused(e)
|
|
51
|
+
result.with_confirmation("unconfirmed_map(a covering spec from the whole-suite map was refused)")
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
warn_failed(result, e)
|
|
54
|
+
unconfirmed(result, "confirmation failed: #{e.class}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The map named a spec that is not a spec file inside the project: the
|
|
58
|
+
# map cannot be trusted, so its survivors stay counted (#1286).
|
|
59
|
+
def warn_refused(error)
|
|
60
|
+
return if @warned_refused
|
|
61
|
+
|
|
62
|
+
@warned_refused = true
|
|
63
|
+
@diagnostics.warn("survivor confirmation refused the whole-suite coverage map: " \
|
|
64
|
+
"#{error.message.lines.first.strip}; its survivors stay counted; " \
|
|
65
|
+
"run `saboteur run --all` to rebuild it")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One line per run, however many survivors hit the same failure.
|
|
69
|
+
def warn_failed(result, error)
|
|
70
|
+
return if @warned_failure
|
|
71
|
+
|
|
72
|
+
@warned_failure = true
|
|
73
|
+
@diagnostics.warn("survivor confirmation failed for #{result.subject.id} #{result.digest} " \
|
|
74
|
+
"(#{error.class}: #{error.message.lines.map(&:strip).reject(&:empty?).first}); " \
|
|
75
|
+
"its survivors stay unconfirmed, outside the score")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def confirm_checked(result)
|
|
43
79
|
return unconfirmed(result, "the confirmation budget is spent") if @remaining <= 0
|
|
44
80
|
return unconfirmed(result, "the --max-duration budget is spent") if @budget_exceeded.call
|
|
45
81
|
|
|
46
|
-
|
|
47
|
-
|
|
82
|
+
# Nothing in the whole-suite map covers the edited lines (the tree
|
|
83
|
+
# drifted since the map was built): the map cannot confirm anything.
|
|
84
|
+
covering = covering_tests(result)
|
|
85
|
+
return map_silent(result) if covering.empty?
|
|
48
86
|
|
|
87
|
+
tests = unrun_covering_tests(result, covering)
|
|
88
|
+
return result.with_confirmation("confirmed") if tests.empty?
|
|
89
|
+
|
|
90
|
+
load_unselected_specs(tests)
|
|
49
91
|
outcome = run_confirmation(result, tests)
|
|
50
92
|
@remaining -= outcome.runtime || 0
|
|
51
93
|
case outcome.status
|
|
@@ -58,8 +100,14 @@ module Saboteur
|
|
|
58
100
|
# The whole-suite map's covering tests for the mutant's edited lines
|
|
59
101
|
# (branch evidence intersected, mirroring the scheduler), minus the
|
|
60
102
|
# tests the mutant already ran, fastest first, capped.
|
|
61
|
-
def unrun_covering_tests(result)
|
|
62
|
-
fastest_first(
|
|
103
|
+
def unrun_covering_tests(result, covering)
|
|
104
|
+
fastest_first(covering - (result.executed || result.covered_by)).first(MAX_SPECS_PER_SURVIVOR)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The covering specs may live in files this run never loaded; the
|
|
108
|
+
# forked confirmation child can only run examples already in its world.
|
|
109
|
+
def load_unselected_specs(tests)
|
|
110
|
+
@load_specs.call(tests.map { |id| id.sub(/\[[\d:]+\]\z/, "") })
|
|
63
111
|
end
|
|
64
112
|
|
|
65
113
|
def covering_tests(result)
|
|
@@ -86,11 +134,12 @@ module Saboteur
|
|
|
86
134
|
# becomes an error; a check the budgets never let run comes back
|
|
87
135
|
# unverified - outside the score and never cached.
|
|
88
136
|
def checked_kill(result, tests, outcome)
|
|
89
|
-
kill = result.with_confirmation(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
kill = result.with_confirmation(
|
|
138
|
+
"killed_by_confirmation", status: :killed, reason: outcome.message, killed_by: outcome.failed_ids,
|
|
139
|
+
covered_by: tests, kill_cause: outcome.kill_cause,
|
|
140
|
+
runtime: (result.runtime || 0) + (outcome.runtime || 0),
|
|
141
|
+
executed: (result.executed || []) + outcome.executed_ids
|
|
142
|
+
)
|
|
94
143
|
checked = VerdictCheck.new(run_unchanged: unchanged_runner(result.subject),
|
|
95
144
|
baseline_timeout: @config["baseline_timeout"]).call([kill]).first
|
|
96
145
|
return checked unless checked.status == :killed
|
|
@@ -108,7 +157,7 @@ module Saboteur
|
|
|
108
157
|
# (#1079). ResultCache#record replaces the subject+mutation entry, so
|
|
109
158
|
# the stale survivor verdict cannot win a later lookup.
|
|
110
159
|
def record_kill(result, kill)
|
|
111
|
-
@cache&.record(kill.with_confirmation("
|
|
160
|
+
@cache&.record(kill.with_confirmation("killed_by_confirmation", covered_by: covering_tests(result)))
|
|
112
161
|
end
|
|
113
162
|
|
|
114
163
|
# The unchanged-method re-run VerdictCheck checks a kill against, in
|
|
@@ -129,6 +178,13 @@ module Saboteur
|
|
|
129
178
|
end
|
|
130
179
|
end
|
|
131
180
|
|
|
181
|
+
# The map says nothing about the edited lines (the tree drifted since it
|
|
182
|
+
# was built): the survivor stays counted and the pass says so once.
|
|
183
|
+
def map_silent(result)
|
|
184
|
+
@map_silent += 1
|
|
185
|
+
result.with_confirmation("unconfirmed_map(no covering tests in the whole-suite map)")
|
|
186
|
+
end
|
|
187
|
+
|
|
132
188
|
def unconfirmed(result, reason)
|
|
133
189
|
result.with_confirmation("unconfirmed(#{reason})")
|
|
134
190
|
end
|
|
@@ -148,6 +204,14 @@ module Saboteur
|
|
|
148
204
|
durations.empty? ? nil : durations.sum / durations.size
|
|
149
205
|
end
|
|
150
206
|
|
|
207
|
+
def warn_map_silent
|
|
208
|
+
return if @map_silent.zero?
|
|
209
|
+
|
|
210
|
+
@diagnostics.warn("survivor confirmation: the whole-suite coverage map covers none of the edited lines of " \
|
|
211
|
+
"#{@map_silent} survivor#{'s' unless @map_silent == 1}, so they stay counted; " \
|
|
212
|
+
"run `saboteur run --all` to rebuild it")
|
|
213
|
+
end
|
|
214
|
+
|
|
151
215
|
def summarize(results)
|
|
152
216
|
counts = results.filter_map(&:confirmation).tally
|
|
153
217
|
return if counts.empty?
|
|
@@ -116,7 +116,8 @@ module Saboteur
|
|
|
116
116
|
# Why the subjectless files needed no spec.
|
|
117
117
|
def subjectless_message(since:)
|
|
118
118
|
detail = since ? "no changed mutation subjects" : "no mutation subjects"
|
|
119
|
-
|
|
119
|
+
who = subjectless.size == 1 ? "it has" : "they have"
|
|
120
|
+
"inferred specs: no spec is required for #{names(subjectless)}: #{who} #{detail}"
|
|
120
121
|
end
|
|
121
122
|
|
|
122
123
|
# The per-file warning when the run proceeds with the mapped
|
data/lib/saboteur/engine.rb
CHANGED
|
@@ -31,11 +31,6 @@ module Saboteur
|
|
|
31
31
|
include GatedRun
|
|
32
32
|
include SelectionAnnouncement
|
|
33
33
|
|
|
34
|
-
# Confirmation only guards a partial coverage map, so it is silent unless
|
|
35
|
-
# the user opted into partial_coverage = use (#1228).
|
|
36
|
-
CONFIRMATION_SKIPPED = "survivor confirmation skipped: partial_coverage = use applies only to a cached " \
|
|
37
|
-
"whole-suite run (no --test, one coverage part)"
|
|
38
|
-
|
|
39
34
|
# Everything besides sources and tests that can change a verdict.
|
|
40
35
|
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
|
|
41
36
|
timeout_factor timeout_min
|
|
@@ -130,6 +125,12 @@ module Saboteur
|
|
|
130
125
|
@coverage_store&.whole_suite_map
|
|
131
126
|
end
|
|
132
127
|
|
|
128
|
+
# True when this run did not build a whole-suite map: a diff, named
|
|
129
|
+
# subjects, an explicit --test or inferred specs (#1286).
|
|
130
|
+
def scoped_selection?
|
|
131
|
+
!whole_suite_run?
|
|
132
|
+
end
|
|
133
|
+
|
|
133
134
|
# False when caching is off, where no coverage build can ever leave a
|
|
134
135
|
# whole-suite map to consult, so advising one would be wrong (review C1).
|
|
135
136
|
def whole_suite_map_lookup?
|
|
@@ -292,15 +293,21 @@ module Saboteur
|
|
|
292
293
|
confirm_survivors(run_candidates(subjects, lists, scheduler, cache, ignore_list), scheduler, cache, ignore_list)
|
|
293
294
|
end
|
|
294
295
|
|
|
296
|
+
# Confirm each live survivor against the whole-suite coverage map (#1286):
|
|
297
|
+
# the covering specs the run's own selection never loaded get one budgeted
|
|
298
|
+
# run. Scoped runs (--since, --subject, pr) are the point, so nothing here
|
|
299
|
+
# depends on partial_coverage or on the run being unscoped. Without a
|
|
300
|
+
# whole-suite map there is nothing to confirm against; the run says so
|
|
301
|
+
# elsewhere (survivors unconfirmed: no whole-suite coverage map).
|
|
295
302
|
def confirm_survivors(results, scheduler, cache, ignore_list)
|
|
296
|
-
|
|
297
|
-
live = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
|
|
298
|
-
@diagnostics.inform(CONFIRMATION_SKIPPED) if live && config["partial_coverage"] == "use"
|
|
299
|
-
return results
|
|
300
|
-
end
|
|
303
|
+
return results if results.none? { |result| result.survived? && !ignore_list.accepts?(result) }
|
|
301
304
|
|
|
302
|
-
|
|
303
|
-
|
|
305
|
+
whole = whole_suite_coverage_map
|
|
306
|
+
return results if whole.nil?
|
|
307
|
+
|
|
308
|
+
SurvivorConfirmation.new(config: config, map: whole, scheduler: scheduler, diagnostics: @diagnostics,
|
|
309
|
+
budget_exceeded: method(:budget_exceeded?), cache: cache,
|
|
310
|
+
load_specs: ->(files) { scheduler.runner.integration.load_spec_files(files) })
|
|
304
311
|
.call(results, ignore_list: ignore_list, early_stop: @early_stop)
|
|
305
312
|
end
|
|
306
313
|
|
|
@@ -350,10 +357,19 @@ module Saboteur
|
|
|
350
357
|
# baseline's own chatter (demoted to --verbose) would start.
|
|
351
358
|
def announce_quiet_start(store, files)
|
|
352
359
|
@map_cached = store.reusable?(files)
|
|
353
|
-
@diagnostics.quiet_start_header(scope:
|
|
360
|
+
@diagnostics.quiet_start_header(scope: quiet_scope,
|
|
354
361
|
seed: config["seed"], operators: config.operator_policy["effective"].size)
|
|
355
362
|
end
|
|
356
363
|
|
|
364
|
+
# Names what the run covers: the diff, the --subject filter, or the
|
|
365
|
+
# whole project (P6) - a --subject run is not "whole project".
|
|
366
|
+
def quiet_scope
|
|
367
|
+
parts = []
|
|
368
|
+
parts << "--since #{config['since']}" if config["since"]
|
|
369
|
+
parts << "--subject #{config['subjects'].join(', ')}" if config["subjects"].any?
|
|
370
|
+
parts.empty? ? "whole project" : parts.join(", ")
|
|
371
|
+
end
|
|
372
|
+
|
|
357
373
|
def inform_about_run(subjects, map)
|
|
358
374
|
if @diagnostics.quiet_start && !@diagnostics.verbose
|
|
359
375
|
@diagnostics.quiet_baseline(passes: @completed_baseline_passes, elapsed: @baseline_elapsed,
|
|
@@ -367,11 +383,13 @@ module Saboteur
|
|
|
367
383
|
end
|
|
368
384
|
|
|
369
385
|
# This run's map is the whole-suite map only when nothing scoped it:
|
|
370
|
-
# no --since, no subjects, and
|
|
371
|
-
#
|
|
386
|
+
# no --since, no subjects, and every spec file loaded (--all or
|
|
387
|
+
# c.test_selection = :all). Inferred specs are a selection, never the
|
|
388
|
+
# whole suite: a spec with no matching source file is not in the map
|
|
389
|
+
# (#1286).
|
|
372
390
|
def whole_suite_run?
|
|
373
391
|
config["since"].nil? && config["subjects"].empty? &&
|
|
374
|
-
|
|
392
|
+
config["tests"].empty? && @guessed_tests.nil?
|
|
375
393
|
end
|
|
376
394
|
|
|
377
395
|
def coverage_store(integration, load_lines, load_methods = {})
|
|
@@ -18,10 +18,13 @@ module Saboteur
|
|
|
18
18
|
# else the class of the error it died with (a crash, not a check).
|
|
19
19
|
# executed_ids: the tests the child reports it ran; empty when it could
|
|
20
20
|
# not say (a test called exit).
|
|
21
|
+
# value_differed: whether the mutated expression ever produced a value
|
|
22
|
+
# different from the original (SiteProbe); nil when the site is not
|
|
23
|
+
# probeable, never ran, or the run did not survive (#1290).
|
|
21
24
|
Outcome = Data.define(:status, :runtime, :failed_ids, :message, :leftover_pids, :kill_cause,
|
|
22
|
-
:executed_ids) do
|
|
25
|
+
:executed_ids, :value_differed) do
|
|
23
26
|
def initialize(status:, runtime:, failed_ids: [], message: nil, leftover_pids: [], kill_cause: nil,
|
|
24
|
-
executed_ids: [])
|
|
27
|
+
executed_ids: [], value_differed: nil)
|
|
25
28
|
super
|
|
26
29
|
end
|
|
27
30
|
end
|
|
@@ -74,6 +77,9 @@ module Saboteur
|
|
|
74
77
|
# sandbox: when given, a callable run first in every child (Landlock
|
|
75
78
|
# makes the project tree read-only there, #152). It raises rather than
|
|
76
79
|
# run half-restricted, which the child reports as an error.
|
|
80
|
+
# Survivor confirmation loads the spec files a scoped run left out (#1286).
|
|
81
|
+
attr_reader :integration
|
|
82
|
+
|
|
77
83
|
def initialize(integration:, reevaluator:, log_dir:, hooks: Hooks.new, supervisor: Supervisor.new, seed: 1,
|
|
78
84
|
sandbox: nil, canary: nil, crash_log: nil)
|
|
79
85
|
@integration = integration
|
|
@@ -95,6 +101,7 @@ module Saboteur
|
|
|
95
101
|
def run(subject:, text:, test_ids:, timeout:, mutation: nil, eval_prefix: nil)
|
|
96
102
|
@hooks.run(:before_fork, mutation)
|
|
97
103
|
@sequence = @sequence.to_i + 1
|
|
104
|
+
text = SiteProbe.text(subject, mutation, text) || text if mutation
|
|
98
105
|
job = Job.new(subject:, text:, test_ids:, timeout:, mutation:, eval_prefix:,
|
|
99
106
|
log_path: File.join(@log_dir, "child-#{Process.pid}-#{@sequence}.log"))
|
|
100
107
|
log_file = open_log(job)
|
|
@@ -129,6 +136,7 @@ module Saboteur
|
|
|
129
136
|
# Before the evaluation: a whole-file subject runs the file's own
|
|
130
137
|
# top-level code there, and it must see the run's random numbers.
|
|
131
138
|
Kernel.srand(@seed)
|
|
139
|
+
SiteProbe.reset
|
|
132
140
|
evaluate_mutant(writer, job)
|
|
133
141
|
stale = stale_captures(job)
|
|
134
142
|
Protocol.emit(writer, "captures", site: stale.first, stale: stale.size) if stale.any?
|
|
@@ -167,7 +175,8 @@ module Saboteur
|
|
|
167
175
|
Protocol.emit(writer, "error", message: "around_mutant hook did not run the tests")
|
|
168
176
|
else
|
|
169
177
|
called = flags[:replacement_called]
|
|
170
|
-
Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called
|
|
178
|
+
Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called,
|
|
179
|
+
value_differed: SiteProbe.differed)
|
|
171
180
|
# A reload after the mutant already ran (an after-hook restore)
|
|
172
181
|
# is no reason to doubt the verdict; only a never-invoked mutant
|
|
173
182
|
# can have been hidden by the overwrite.
|
|
@@ -391,7 +400,8 @@ module Saboteur
|
|
|
391
400
|
return mismatch(runtime, "executed #{executed.size} of #{test_ids.size} requested tests")
|
|
392
401
|
end
|
|
393
402
|
|
|
394
|
-
Outcome.new(status: :survived, runtime: runtime, executed_ids: executed
|
|
403
|
+
Outcome.new(status: :survived, runtime: runtime, executed_ids: executed,
|
|
404
|
+
value_differed: result["value_differed"])
|
|
395
405
|
end
|
|
396
406
|
|
|
397
407
|
def killed_outcome(result, runtime, failed, executed, verbose_failure)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Execution
|
|
8
|
+
# Records, in the mutant's child, whether the mutated expression produced
|
|
9
|
+
# a different value from the original one (#1290). The mutated site is
|
|
10
|
+
# wrapped as (observe((mutated), [operands]) { original }). The mutated
|
|
11
|
+
# expression runs exactly once and first, as it does unwrapped, so the
|
|
12
|
+
# suite sees the same calls and the same verdict. The original runs
|
|
13
|
+
# only when every operand is a core immutable (Integer, Float, Rational,
|
|
14
|
+
# Symbol, nil, true, false, a frozen String), so only built-in operators
|
|
15
|
+
# execute; with any other operand the site is not probed and reports
|
|
16
|
+
# nil. Only side-effect-free site shapes are wrapped at all (literals,
|
|
17
|
+
# local and instance variable reads, arithmetic and comparison on
|
|
18
|
+
# those).
|
|
19
|
+
module SiteProbe
|
|
20
|
+
OPERATORS = %i[+ - * / % ** < <= > >= == != <=> & | ^ ! -@ +@ ~].freeze
|
|
21
|
+
LEAVES = [Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::SymbolNode, Prism::NilNode,
|
|
22
|
+
Prism::TrueNode, Prism::FalseNode, Prism::LocalVariableReadNode,
|
|
23
|
+
Prism::InstanceVariableReadNode].freeze
|
|
24
|
+
|
|
25
|
+
@hits = 0
|
|
26
|
+
@differed = false
|
|
27
|
+
@comparable = true
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# The def text with the mutated site wrapped, or nil when the site
|
|
31
|
+
# cannot be probed safely. mutated_text is what the run would
|
|
32
|
+
# evaluate unwrapped; the wrap is only offered when it is exactly
|
|
33
|
+
# the source with the mutation's edits applied.
|
|
34
|
+
def text(subject, mutation, mutated_text)
|
|
35
|
+
return nil unless subject.respond_to?(:whole_file_only?) && mutation.respond_to?(:edits)
|
|
36
|
+
return nil if subject.whole_file_only? || mutation.edits.size != 1
|
|
37
|
+
|
|
38
|
+
source = subject.source
|
|
39
|
+
return nil unless subject.apply(mutation.edits) == mutated_text
|
|
40
|
+
|
|
41
|
+
edit = mutation.edits.first
|
|
42
|
+
range = (edit.start_byte - subject.start_byte)...(edit.end_byte - subject.start_byte)
|
|
43
|
+
wrap(source.b, range, edit.replacement.b)&.force_encoding(Encoding::UTF_8)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil # a probe must never turn a mutant run into an error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset
|
|
49
|
+
@hits = 0
|
|
50
|
+
@differed = false
|
|
51
|
+
@comparable = true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# nil: the site never ran, or its values could not be compared.
|
|
55
|
+
def differed
|
|
56
|
+
return nil if @hits.zero? || !@comparable
|
|
57
|
+
|
|
58
|
+
@differed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def observe(mutated, operands, &)
|
|
62
|
+
compare(mutated, &) if operands.all? { |operand| immutable?(operand) }
|
|
63
|
+
mutated
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def immutable?(value)
|
|
69
|
+
case value
|
|
70
|
+
when Integer, Float, Rational, Symbol, nil, true, false then true
|
|
71
|
+
when String then value.frozen?
|
|
72
|
+
else false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def compare(mutated)
|
|
77
|
+
original = yield
|
|
78
|
+
@hits += 1
|
|
79
|
+
@differed ||= original != mutated
|
|
80
|
+
rescue StandardError
|
|
81
|
+
@comparable = false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def wrap(source, range, replacement)
|
|
85
|
+
top = site(Prism.parse(source).value, range)
|
|
86
|
+
# A bare edited literal differs from itself by construction: no signal.
|
|
87
|
+
return nil if top.nil? || LEAVES.any? { |leaf| top.is_a?(leaf) }
|
|
88
|
+
|
|
89
|
+
from = top.location.start_offset
|
|
90
|
+
to = top.location.end_offset
|
|
91
|
+
original = source.byteslice(from...to)
|
|
92
|
+
mutated = original.byteslice(0, range.begin - from) + replacement + original.byteslice((range.end - from)..)
|
|
93
|
+
return nil if (original + mutated).include?("\n")
|
|
94
|
+
|
|
95
|
+
source.byteslice(0, from) + probe(top, original, mutated) + source.byteslice(to..)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def probe(top, original, mutated)
|
|
99
|
+
reads = []
|
|
100
|
+
collect_reads(top, reads)
|
|
101
|
+
operands = reads.uniq.join(", ")
|
|
102
|
+
"(::Saboteur::Execution::SiteProbe.observe((#{mutated}), [#{operands}]) { #{original} })".b
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def collect_reads(node, reads)
|
|
106
|
+
reads << node.slice if node.is_a?(Prism::LocalVariableReadNode) || node.is_a?(Prism::InstanceVariableReadNode)
|
|
107
|
+
node.compact_child_nodes.each { |child| collect_reads(child, reads) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The outermost pure expression around the edit, climbing while the
|
|
111
|
+
# parent is pure too.
|
|
112
|
+
def site(root, range)
|
|
113
|
+
path = path_to(root, range).grep_v(Prism::ArgumentsNode)
|
|
114
|
+
index = path.rindex { |node| pure?(node) }
|
|
115
|
+
return nil unless index
|
|
116
|
+
|
|
117
|
+
index -= 1 while index.positive? && climbable?(path[index - 1])
|
|
118
|
+
path[index]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def climbable?(node)
|
|
122
|
+
(node.is_a?(Prism::CallNode) || node.is_a?(Prism::ParenthesesNode)) && pure?(node)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def path_to(node, range)
|
|
126
|
+
path = [node]
|
|
127
|
+
loop do
|
|
128
|
+
child = node.compact_child_nodes.find do |c|
|
|
129
|
+
c.location.start_offset <= range.begin && range.end <= c.location.end_offset
|
|
130
|
+
end
|
|
131
|
+
return path unless child
|
|
132
|
+
|
|
133
|
+
path << child
|
|
134
|
+
node = child
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def pure?(node)
|
|
139
|
+
case node
|
|
140
|
+
when *LEAVES then true
|
|
141
|
+
when Prism::ParenthesesNode then pure_parentheses?(node)
|
|
142
|
+
when Prism::CallNode then pure_call?(node)
|
|
143
|
+
else false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def pure_parentheses?(node)
|
|
148
|
+
body = node.body
|
|
149
|
+
body.is_a?(Prism::StatementsNode) && body.body.size == 1 && pure?(body.body.first)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def pure_call?(node)
|
|
153
|
+
return false unless OPERATORS.include?(node.name) && node.block.nil? && node.receiver
|
|
154
|
+
|
|
155
|
+
pure?(node.receiver) && Array(node.arguments&.arguments).size <= 1 &&
|
|
156
|
+
Array(node.arguments&.arguments).all? { |arg| pure?(arg) }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
data/lib/saboteur/gates.rb
CHANGED
|
@@ -205,7 +205,7 @@ module Saboteur
|
|
|
205
205
|
|
|
206
206
|
# A subject fails a gate when at least one of its mutants survived.
|
|
207
207
|
def failing_subjects(scored)
|
|
208
|
-
scored.select
|
|
208
|
+
scored.select(&:survived?).map(&:subject).uniq.size
|
|
209
209
|
end
|
|
210
210
|
|
|
211
211
|
def over?(count, limit, executed)
|
|
@@ -124,6 +124,36 @@ module Saboteur
|
|
|
124
124
|
world.example_groups.map { |group| File.expand_path(group.metadata[:file_path]) }.uniq
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# Loads spec files the run's own selection left out, in the parent, so a
|
|
128
|
+
# forked confirmation run can execute their examples (#1286). Files
|
|
129
|
+
# already loaded are skipped; a file that defines no group is an error,
|
|
130
|
+
# since its examples could never run.
|
|
131
|
+
def load_spec_files(paths)
|
|
132
|
+
loaded = test_files
|
|
133
|
+
absolute = paths.map { |path| File.expand_path(path, config.root) }.uniq
|
|
134
|
+
(absolute - loaded).each do |path|
|
|
135
|
+
require_inside_root(path)
|
|
136
|
+
before = world.example_groups.size
|
|
137
|
+
from_directory(config.root) { load(path) }
|
|
138
|
+
raise RefusedSpecPath, "#{path} defines no example group" if world.example_groups.size == before
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The ids come from a cached coverage map, which is untrusted input
|
|
143
|
+
# (#427): only a spec file that really lives under the project root,
|
|
144
|
+
# symlinks resolved, may be loaded.
|
|
145
|
+
def require_inside_root(path)
|
|
146
|
+
raise RefusedSpecPath, "refusing to load #{path}: not a *_spec.rb file" unless path.end_with?("_spec.rb")
|
|
147
|
+
|
|
148
|
+
root = File.realpath(config.root)
|
|
149
|
+
real = File.realpath(path)
|
|
150
|
+
return if real.start_with?("#{root}#{File::SEPARATOR}") && real.end_with?("_spec.rb")
|
|
151
|
+
|
|
152
|
+
raise RefusedSpecPath, "refusing to load #{path}: it resolves outside the project root"
|
|
153
|
+
rescue SystemCallError
|
|
154
|
+
raise RefusedSpecPath, "refusing to load #{path}: it does not exist under the project root"
|
|
155
|
+
end
|
|
156
|
+
|
|
127
157
|
# Runs the tests in the order given: groups by their earliest example,
|
|
128
158
|
# examples by position.
|
|
129
159
|
def run(ids)
|