saboteur 0.7.0 → 0.8.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 +657 -0
- data/README.md +198 -1077
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +6 -2
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +53 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +71 -54
- data/lib/saboteur/config.rb +98 -5
- data/lib/saboteur/config_file.rb +8 -0
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +9 -0
- data/lib/saboteur/engine/baseline_guard.rb +62 -14
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +19 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +32 -10
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +35 -0
- data/lib/saboteur/engine/load_coverage.rb +45 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +107 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +27 -0
- data/lib/saboteur/engine/sandbox_policy.rb +38 -0
- data/lib/saboteur/engine/scheduler.rb +109 -3
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +295 -20
- data/lib/saboteur/engine/test_guess.rb +315 -25
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +100 -127
- data/lib/saboteur/execution/external_runner.rb +471 -0
- data/lib/saboteur/execution/marker_sweep.rb +79 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +12 -0
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +15 -2
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +27 -10
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +135 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +26 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +120 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +40 -7
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +105 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +13 -0
- metadata +35 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
4
6
|
require_relative "process_tree"
|
|
5
7
|
require_relative "protocol"
|
|
6
8
|
|
|
@@ -21,13 +23,23 @@ module Saboteur
|
|
|
21
23
|
# 4. Refusing recursion: the child is marked with CHILD_ENV, and Engine
|
|
22
24
|
# refuses to start a run inside a marked process.
|
|
23
25
|
# 5. On Linux, the supervisor is a child subreaper, so escaped orphans are
|
|
24
|
-
# re-parented to it instead of init and stay
|
|
26
|
+
# re-parented to it instead of init and stay reaped by it.
|
|
27
|
+
# 6. Every child carries a unique marker in its environment; the tree
|
|
28
|
+
# poll can miss a descendant that leaves the group and loses its
|
|
29
|
+
# intermediate parent between polls (#365), so kill_all also sweeps
|
|
30
|
+
# the process table for the marker, which survives both escapes.
|
|
25
31
|
class Supervisor
|
|
26
32
|
# Set in every child to its nesting depth (1 for a child of a top-level
|
|
27
33
|
# run). Engine refuses to start when it is set; the supervisor itself
|
|
28
34
|
# refuses to fork past MAX_DEPTH, so even a run with that guard removed
|
|
29
35
|
# (a mutant of the guard, say) cannot recurse without bound.
|
|
30
36
|
CHILD_ENV = "SABOTEUR_CHILD"
|
|
37
|
+
# Colon-joined marker list in a child's environment: the run's own
|
|
38
|
+
# token last, its ancestors' before it, so any ancestor's sweep still
|
|
39
|
+
# names a leaked nested run. See ProcessTree's marker sweep.
|
|
40
|
+
TREE_ENV = "SABOTEUR_TREE"
|
|
41
|
+
# The comm prefix of a supervised child; comm holds 15 bytes.
|
|
42
|
+
COMM_PREFIX = "sbt"
|
|
31
43
|
MAX_DEPTH = 4
|
|
32
44
|
# The hard CPU limit, as a multiple of the budget (see limit_cpu).
|
|
33
45
|
HARD_CPU_FACTOR = 20
|
|
@@ -47,8 +59,8 @@ module Saboteur
|
|
|
47
59
|
# least half of the memory limit, the evidence that a SIGKILL may be
|
|
48
60
|
# an out-of-memory kill.
|
|
49
61
|
Result = Data.define(:payload, :exit_status, :stopped_by, :detail, :runtime, :leftover_pids,
|
|
50
|
-
:supervisor_killed, :memory_pressure) do
|
|
51
|
-
def initialize(supervisor_killed: false, memory_pressure: false, **fields)
|
|
62
|
+
:leftover_notes, :supervisor_killed, :memory_pressure) do
|
|
63
|
+
def initialize(leftover_notes: [], supervisor_killed: false, memory_pressure: false, **fields)
|
|
52
64
|
super
|
|
53
65
|
end
|
|
54
66
|
|
|
@@ -132,6 +144,9 @@ module Saboteur
|
|
|
132
144
|
@peak_growth_kb = 0
|
|
133
145
|
supervisor = Process.pid
|
|
134
146
|
@child_label = label
|
|
147
|
+
# Read by the forked child through its copy of this process, like
|
|
148
|
+
# @child_label above.
|
|
149
|
+
@marker = SecureRandom.hex(6)
|
|
135
150
|
pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
|
|
136
151
|
writer.close
|
|
137
152
|
contain(pid, reader, started, timeout, on_tick)
|
|
@@ -147,6 +162,8 @@ module Saboteur
|
|
|
147
162
|
reader.close
|
|
148
163
|
self.class.close_in_child.each { |io| io.close unless io.closed? }
|
|
149
164
|
ENV[CHILD_ENV] = depth.to_s
|
|
165
|
+
ENV[TREE_ENV] = [ENV.fetch(TREE_ENV, nil), @marker].compact.join(":")
|
|
166
|
+
MarkerSweep.write_comm(@marker)
|
|
150
167
|
# Best-effort like the parent-side own_group: macOS can refuse the
|
|
151
168
|
# group change (EPERM), and the child must still run (#296).
|
|
152
169
|
own_group(0)
|
|
@@ -210,7 +227,7 @@ module Saboteur
|
|
|
210
227
|
# --- parent ---------------------------------------------------------
|
|
211
228
|
|
|
212
229
|
def contain(pid, reader, started, timeout, on_tick)
|
|
213
|
-
tree = ProcessTree.new(pid)
|
|
230
|
+
tree = ProcessTree.new(pid, marker: @marker)
|
|
214
231
|
self.class.live[pid] = tree
|
|
215
232
|
own_group(pid)
|
|
216
233
|
deadline = started + timeout
|
|
@@ -226,27 +243,27 @@ module Saboteur
|
|
|
226
243
|
# (progress IO failed) or any other error must not skip the
|
|
227
244
|
# kill/reap, or the child and its tree would run unsupervised
|
|
228
245
|
# until this process exits.
|
|
229
|
-
exit_status, supervisor_killed, leftover_pids = release(pid, tree, exit_status, stop, grace_expired)
|
|
246
|
+
exit_status, supervisor_killed, leftover_pids, notes = release(pid, tree, exit_status, stop, grace_expired)
|
|
230
247
|
end
|
|
231
248
|
Result.new(payload: payload, exit_status: exit_status, stopped_by: stop, detail: detail,
|
|
232
|
-
runtime: monotonic - started, leftover_pids: leftover_pids,
|
|
249
|
+
runtime: monotonic - started, leftover_pids: leftover_pids, leftover_notes: notes,
|
|
233
250
|
supervisor_killed: supervisor_killed, memory_pressure: memory_pressure?)
|
|
234
251
|
end
|
|
235
252
|
|
|
236
|
-
# Kills and reaps the child and
|
|
237
|
-
# [exit_status, supervisor_killed, leftover_pids].
|
|
253
|
+
# Kills and reaps the child and its tree; returns [exit_status, supervisor_killed, leftover_pids, notes].
|
|
238
254
|
def release(pid, tree, exit_status, stop, grace_expired)
|
|
239
255
|
exit_status ||= terminate(pid) if stop == :timeout
|
|
240
256
|
supervisor_killed = false
|
|
241
257
|
# The grace ran out with the child seemingly alive. What ends it is
|
|
242
258
|
# only ours to claim if we see it alive: stop it first.
|
|
243
259
|
exit_status, supervisor_killed = freeze_child(pid) if grace_expired
|
|
244
|
-
|
|
260
|
+
# Notes name the leftovers before the kill erases their command lines (#486).
|
|
261
|
+
leftover_pids, notes = tree.kill_all_except(pid)
|
|
245
262
|
exit_status ||= reap(pid)
|
|
246
263
|
self.class.live.delete(pid)
|
|
247
264
|
reap_adopted(leftover_pids)
|
|
248
265
|
reap_group(pid)
|
|
249
|
-
[exit_status, supervisor_killed, leftover_pids]
|
|
266
|
+
[exit_status, supervisor_killed, leftover_pids, notes]
|
|
250
267
|
end
|
|
251
268
|
|
|
252
269
|
# After EOF, waits for the child to exit by itself while still checking
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "../text"
|
|
6
|
+
require_relative "peak_memory"
|
|
6
7
|
require_relative "process_tree"
|
|
7
8
|
require_relative "supervisor"
|
|
8
9
|
|
|
@@ -26,12 +27,17 @@ module Saboteur
|
|
|
26
27
|
class WorkerPool
|
|
27
28
|
# done: items finished; status: the Process::Status once reaped;
|
|
28
29
|
# last_words: what the worker reported before dying, if anything.
|
|
29
|
-
Worker = Struct.new(:number, :pid, :commands, :results, :current, :done, :status, :last_words)
|
|
30
|
+
Worker = Struct.new(:number, :pid, :commands, :results, :current, :done, :status, :last_words, :peak_rss_mb)
|
|
30
31
|
START_FAILURES = 3
|
|
31
32
|
|
|
32
33
|
# on_start: called in each worker with its number before its first
|
|
33
34
|
# item (per-worker database connections, for example).
|
|
35
|
+
# worker_peaks: worker number => peak RSS in MB, filled as workers
|
|
36
|
+
# retire (#202). Empty for an inline (jobs 1) run.
|
|
37
|
+
attr_reader :worker_peaks
|
|
38
|
+
|
|
34
39
|
def initialize(jobs:, on_start: nil, on_result: nil, drain_timeout: 30)
|
|
40
|
+
@worker_peaks = {}
|
|
35
41
|
@jobs = jobs
|
|
36
42
|
@on_start = on_start
|
|
37
43
|
@on_result = on_result
|
|
@@ -154,6 +160,10 @@ module Saboteur
|
|
|
154
160
|
# Stores a line from the worker: its item's result, or why it crashed.
|
|
155
161
|
def take(worker, line, results)
|
|
156
162
|
data = parse(line)
|
|
163
|
+
if data.key?("peak_rss_mb")
|
|
164
|
+
@worker_peaks[data.delete("worker") || worker.number] = worker.peak_rss_mb = data["peak_rss_mb"]
|
|
165
|
+
return
|
|
166
|
+
end
|
|
157
167
|
return worker.last_words = data["fatal"] if data.key?("fatal")
|
|
158
168
|
return unless worker.current
|
|
159
169
|
|
|
@@ -285,6 +295,7 @@ module Saboteur
|
|
|
285
295
|
results.puts(JSON.generate(run_one(items[Integer(line)], &)))
|
|
286
296
|
results.flush
|
|
287
297
|
end
|
|
298
|
+
report_peak(results, number)
|
|
288
299
|
exit!(0)
|
|
289
300
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
290
301
|
last_words(results, "worker #{number} crashed: #{e.class}: #{e.message} (#{e.backtrace&.first})")
|
|
@@ -298,6 +309,16 @@ module Saboteur
|
|
|
298
309
|
nil
|
|
299
310
|
end
|
|
300
311
|
|
|
312
|
+
# Last word before exit!: the kernel's exact peak RSS of this worker,
|
|
313
|
+
# for the run's memory report (#202). Skipped where procfs is absent.
|
|
314
|
+
def report_peak(results, number)
|
|
315
|
+
peak = PeakMemory.peak_mb
|
|
316
|
+
return unless peak
|
|
317
|
+
|
|
318
|
+
results.puts(JSON.generate("worker" => number, "peak_rss_mb" => peak))
|
|
319
|
+
results.flush
|
|
320
|
+
end
|
|
321
|
+
|
|
301
322
|
def run_one(item, &work)
|
|
302
323
|
work.call(item)
|
|
303
324
|
rescue Error => e
|
|
@@ -319,6 +340,7 @@ module Saboteur
|
|
|
319
340
|
sleep 0.01 until reaped?(worker) || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
320
341
|
tree&.kill_all
|
|
321
342
|
worker.status ||= Process.wait2(worker.pid).last unless reaped?(worker)
|
|
343
|
+
drain(worker, {})
|
|
322
344
|
worker.results.close unless worker.results.closed?
|
|
323
345
|
rescue Errno::ECHILD
|
|
324
346
|
nil
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Frameworks
|
|
6
|
+
# The #387 shape: a suite connects ActiveRecord to a sqlite ":memory:"
|
|
7
|
+
# database and loads its schema at require time. Every saboteur child is
|
|
8
|
+
# a fork, ActiveRecord's fork-safe pool reconnects there, and a fresh
|
|
9
|
+
# in-memory database is EMPTY - the schema is gone, and every example
|
|
10
|
+
# fails with "Could not find table" while plain rspec stays green. A
|
|
11
|
+
# child that detects the shape says so and names the remedies instead of
|
|
12
|
+
# sending the user debugging a healthy suite.
|
|
13
|
+
module SqliteMemoryDatabase
|
|
14
|
+
REMEDY = "a sqlite :memory: database is empty in this forked child: in-memory databases do not " \
|
|
15
|
+
"survive saboteur's fork, so a schema loaded at require time is lost. Load the schema in " \
|
|
16
|
+
"a before(:suite) hook, or in an after_fork hook (config/saboteur_hooks.rb via c.hooks)"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The remedy text when a current pool points at an empty sqlite
|
|
21
|
+
# :memory: database, else nil. Diagnostic only: any error is no hint,
|
|
22
|
+
# never a failure of the run itself.
|
|
23
|
+
def fork_loss_hint
|
|
24
|
+
return unless defined?(ActiveRecord::Base)
|
|
25
|
+
|
|
26
|
+
pools = ActiveRecord::Base.connection_handler.connection_pool_list
|
|
27
|
+
pools.any? { |pool| empty_memory_database?(pool) } ? REMEDY : nil
|
|
28
|
+
rescue StandardError, ScriptError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def empty_memory_database?(pool)
|
|
33
|
+
config = pool.db_config
|
|
34
|
+
return false unless config.adapter.to_s == "sqlite3" && config.database.to_s == ":memory:"
|
|
35
|
+
|
|
36
|
+
pool.with_connection { |connection| user_tables(connection).empty? }
|
|
37
|
+
rescue StandardError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Migration bookkeeping is not user schema: a database with only these
|
|
42
|
+
# tables still has no schema for the suite's models.
|
|
43
|
+
def user_tables(connection)
|
|
44
|
+
connection.select_values("SELECT name FROM sqlite_master WHERE type = 'table'") -
|
|
45
|
+
%w[sqlite_sequence schema_migrations ar_internal_metadata]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/saboteur/gates.rb
CHANGED
|
@@ -41,12 +41,18 @@ module Saboteur
|
|
|
41
41
|
|
|
42
42
|
def initialize(config)
|
|
43
43
|
@limits = SETTINGS.keys.to_h { |key| [key, self.class.limit(config[key], key)] }
|
|
44
|
+
@score_gates = Array(config["score_gates"])
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
attr_reader :cause
|
|
47
48
|
|
|
48
49
|
def any?
|
|
49
|
-
@limits.values.any?
|
|
50
|
+
@limits.values.any? || @score_gates.any?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The flag-style name of the configured gate, for the --agent rejection.
|
|
54
|
+
def agent_option_name
|
|
55
|
+
@score_gates.any? ? "score gates" : "--max-* gates"
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
# --max-errors judges errored mutants, so a threshold run tolerates them.
|
|
@@ -56,6 +62,7 @@ module Saboteur
|
|
|
56
62
|
|
|
57
63
|
# Prints each failed gate to err and remembers the first one's cause for the JSON report.
|
|
58
64
|
def failed?(report, err)
|
|
65
|
+
vacuous_gates(report).each { |note| err.puts("saboteur: #{note}") }
|
|
59
66
|
found = violations(report)
|
|
60
67
|
found.each { |violation| err.puts("saboteur: #{violation.message}") }
|
|
61
68
|
@cause = found.first&.cause
|
|
@@ -69,11 +76,45 @@ module Saboteur
|
|
|
69
76
|
next if limit.nil? || !over?(count, limit, report.executed.size)
|
|
70
77
|
|
|
71
78
|
Violation.new(cause: cause, message: "#{count} #{status} mutants, above #{flag} #{limit}")
|
|
72
|
-
end
|
|
79
|
+
end + score_gate_violations(report)
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
private
|
|
76
83
|
|
|
84
|
+
# Per-path gates (#473): each gate scores the results whose subject file
|
|
85
|
+
# (root-relative) matches its pattern, with the run's own Score formula,
|
|
86
|
+
# and fails under its floor.
|
|
87
|
+
def score_gate_violations(report)
|
|
88
|
+
@score_gates.filter_map do |gate|
|
|
89
|
+
scored = score_gate_subset(gate, report)
|
|
90
|
+
breakdown = Score.of(scored)
|
|
91
|
+
next if breakdown.tested.zero?
|
|
92
|
+
|
|
93
|
+
score = breakdown.value * 100
|
|
94
|
+
next if score >= gate["min_score"]
|
|
95
|
+
|
|
96
|
+
Violation.new(cause: "scoreGate",
|
|
97
|
+
message: "score gate #{gate['pattern']}: score #{format('%.1f', score)} below " \
|
|
98
|
+
"#{gate['min_score']} (#{scored.size} mutants under it)")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A gate with nothing executed under it passes vacuously and says so.
|
|
103
|
+
def vacuous_gates(report)
|
|
104
|
+
@score_gates.filter_map do |gate|
|
|
105
|
+
breakdown = Score.of(score_gate_subset(gate, report))
|
|
106
|
+
"score gate #{gate['pattern']}: nothing executed under it, passing vacuously" if breakdown.tested.zero?
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def score_gate_subset(gate, report)
|
|
111
|
+
prefix = File.join(File.realpath(report.root), "")
|
|
112
|
+
report.results.select do |result|
|
|
113
|
+
relative = result.subject.file.delete_prefix(prefix)
|
|
114
|
+
File.fnmatch?(gate["pattern"], relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
77
118
|
def over?(count, limit, executed)
|
|
78
119
|
return count > limit if limit.is_a?(Integer)
|
|
79
120
|
|
data/lib/saboteur/help.rb
CHANGED
|
@@ -25,7 +25,9 @@ module Saboteur
|
|
|
25
25
|
2 => "Usage or configuration error: fix the command or config/saboteur.rb. A replay --id that matches no " \
|
|
26
26
|
"mutant is only found after the target has booted. A named source or test file that does not exist.",
|
|
27
27
|
3 => "Tool error: saboteur could not finish, or more than half of the executed mutants errored (such " \
|
|
28
|
-
"a run tested almost nothing)
|
|
28
|
+
"a run tested almost nothing), or a --since run skipped every subject of the change (for example " \
|
|
29
|
+
"refinements or no coverage), so the change is unverified. With --threshold, also any errored or " \
|
|
30
|
+
"resource-limited mutant.",
|
|
29
31
|
4 => "Time budget spent: --max-duration stopped the run before the next subject, so the score is " \
|
|
30
32
|
"partial. --max-duration-ok leaves the exit to the plain verdict: 0, 1, or 3 when the budget " \
|
|
31
33
|
"stopped the run before any mutant was tested."
|
|
@@ -44,7 +46,8 @@ module Saboteur
|
|
|
44
46
|
description: "Builds a per-test coverage map, makes small changes to your methods, runs only the " \
|
|
45
47
|
"tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
|
|
46
48
|
"directories after run replace include and exclude. Without --test, the run infers the " \
|
|
47
|
-
"specs named after the mutated files and runs only those
|
|
49
|
+
"specs named after the mutated files and runs only those (with --since, plus every " \
|
|
50
|
+
"spec file the diff adds or changes that the suite loads); a mutated file with no " \
|
|
48
51
|
"conventional spec stops the run with exit 2 before anything boots, and --all keeps " \
|
|
49
52
|
"the whole configured suite. --threshold PERCENT replaces the survivor check: exit 0 " \
|
|
50
53
|
"when the score is at or above PERCENT, exit 1 when it is below. All operators run " \
|
|
@@ -57,6 +60,7 @@ module Saboteur
|
|
|
57
60
|
["Pull request: only methods changed since the target branch, with the inferred specs",
|
|
58
61
|
"saboteur run --since origin/main"],
|
|
59
62
|
["Whole project and whole suite, four workers, repeatable seed", "saboteur run --all --jobs 4 --seed 42"],
|
|
63
|
+
["One deterministic CI shard of four", "saboteur run --all --shard 1/4"],
|
|
60
64
|
["One class or method", "saboteur run --subject 'Person#adult?'"],
|
|
61
65
|
["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
|
|
62
66
|
["Mutate named files against named specs, skipping the whole-suite coverage run",
|
|
@@ -68,13 +72,12 @@ module Saboteur
|
|
|
68
72
|
Command.new(
|
|
69
73
|
name: "pr",
|
|
70
74
|
summary: "Preset: the fast pull-request loop over the lines changed since a ref",
|
|
71
|
-
description: "Expands to saboteur run --baseline-passes 1 --max-duration 10m
|
|
75
|
+
description: "Expands to saboteur run --baseline-passes 1 --max-duration 10m " \
|
|
72
76
|
"--since REF, with REF the first operand (default main): one baseline check, a " \
|
|
73
77
|
"ten-minute wall-clock budget, and mutants limited to the changed methods. The " \
|
|
74
|
-
"preset
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"wins (#244).",
|
|
78
|
+
"preset fails with exit 4 when the budget is spent; the summary counts the " \
|
|
79
|
+
"unrun mutants. Add --max-duration-ok to accept a partial verdict; other " \
|
|
80
|
+
"gates still apply. Every run option still works; explicit flags win (#654).",
|
|
78
81
|
takes_options: true,
|
|
79
82
|
exit_codes: EXIT_CODE_MEANINGS,
|
|
80
83
|
examples: [["Changed lines since main", "saboteur pr"],
|
|
@@ -99,7 +102,8 @@ module Saboteur
|
|
|
99
102
|
summary: "Check the environment, and with --suite run the baseline twice, without mutating anything",
|
|
100
103
|
description: "Boots the target like a run and checks runtime, coverage and boot files. With --suite " \
|
|
101
104
|
"it also runs your suite twice in the same order with one seed and reports whether both " \
|
|
102
|
-
"runs passed; --test PATH limits those runs to some specs. It never mutates anything."
|
|
105
|
+
"runs passed; --test PATH limits those runs to some specs. It never mutates anything. " \
|
|
106
|
+
"It certifies no versions of Rails, databases or tools saboteur never invokes (#318).",
|
|
103
107
|
takes_options: true,
|
|
104
108
|
exit_codes: DOCTOR_EXIT_CODE_MEANINGS,
|
|
105
109
|
examples: [["Check the setup before the first run", "saboteur doctor"],
|
|
@@ -128,6 +132,53 @@ module Saboteur
|
|
|
128
132
|
["Also fail on a score drop of more than 2 points",
|
|
129
133
|
"saboteur baseline check --baseline baseline.json --epsilon 2"]]
|
|
130
134
|
),
|
|
135
|
+
Command.new(
|
|
136
|
+
name: "survivors",
|
|
137
|
+
summary: "Browse a report's survivors in the terminal, read-only",
|
|
138
|
+
description: "Reads the Stryker JSON report without booting the project or rerunning tests. " \
|
|
139
|
+
"Lists every survivor with rank, file, line, operator, digest and triage label; " \
|
|
140
|
+
"filters with --file, --operator and --triage; --ranked uses the report's ranked " \
|
|
141
|
+
"findings order. --show DIGEST prints the diff, the exact replay command and an " \
|
|
142
|
+
"ignore-file entry template for that mutant. It never edits the ignore file.",
|
|
143
|
+
takes_options: false,
|
|
144
|
+
exit_codes: { 0 => "Survivors listed or shown (including none).",
|
|
145
|
+
2 => "Missing, malformed or unreadable report; bad arguments." },
|
|
146
|
+
examples: [["List survivors from the last run", "saboteur survivors .saboteur/report.json"],
|
|
147
|
+
["Review-priority order", "saboteur survivors .saboteur/report.json --ranked"],
|
|
148
|
+
["Diff, replay command and ignore template for one mutant",
|
|
149
|
+
"saboteur survivors .saboteur/report.json --show 1a2b3c4d5e6f7a8b"]]
|
|
150
|
+
),
|
|
151
|
+
Command.new(
|
|
152
|
+
name: "badge",
|
|
153
|
+
summary: "Print a shields.io endpoint badge over the opt-in score history",
|
|
154
|
+
description: "Reads the score history JSONL (c.score_history, or --history PATH) and prints one " \
|
|
155
|
+
"shields.io endpoint badge JSON for the latest record: label, mutation score and " \
|
|
156
|
+
"color (brightgreen at or above --high, default 80; yellow at or above --low, " \
|
|
157
|
+
"default 60; red below). --output PATH writes the file instead of printing. An " \
|
|
158
|
+
"incomplete latest record is refused unless --mark-partial labels the badge as " \
|
|
159
|
+
"partial. No hosted service, no project boot, no tests run.",
|
|
160
|
+
takes_options: true,
|
|
161
|
+
exit_codes: { 0 => "Badge JSON written.",
|
|
162
|
+
2 => "No history path or unexpected arguments.",
|
|
163
|
+
3 => "Empty or unreadable history, an incomplete record without --mark-partial, " \
|
|
164
|
+
"or no tested mutants." },
|
|
165
|
+
examples: [["Badge the recorded score", "saboteur badge"],
|
|
166
|
+
["Write the badge file CI uploads", "saboteur badge --output .saboteur/badge.json"]]
|
|
167
|
+
),
|
|
168
|
+
Command.new(
|
|
169
|
+
name: "trend",
|
|
170
|
+
summary: "Print the score trend over the opt-in score history",
|
|
171
|
+
description: "Reads the score history JSONL (c.score_history, or --history PATH) and prints the " \
|
|
172
|
+
"latest record next to the most recent comparable one (same configured selection, " \
|
|
173
|
+
"operator policy and a complete run), with the covered-score change in percentage " \
|
|
174
|
+
"points. With no comparable earlier record it says so instead of inventing a " \
|
|
175
|
+
"delta. No hosted service, no project boot, no tests run.",
|
|
176
|
+
takes_options: true,
|
|
177
|
+
exit_codes: { 0 => "Trend printed.",
|
|
178
|
+
2 => "No history path or unexpected arguments.",
|
|
179
|
+
3 => "Empty or unreadable history." },
|
|
180
|
+
examples: [["Show the latest score and its change", "saboteur trend"]]
|
|
181
|
+
),
|
|
131
182
|
Command.new(
|
|
132
183
|
name: "version",
|
|
133
184
|
summary: "Print the installed version",
|
|
@@ -165,7 +216,7 @@ module Saboteur
|
|
|
165
216
|
["config/saboteur_ignore.rb", "Accepted survivors: mutation digests with subject and a required reason."],
|
|
166
217
|
[".saboteur/report.json", "Stryker mutation-testing-report-schema JSON (--json changes the path)."],
|
|
167
218
|
[".saboteur/report.html", "Self-contained HTML report (--html changes the path)."],
|
|
168
|
-
[".saboteur/", "Cache: coverage map and incremental verdicts. Safe to delete."]
|
|
219
|
+
[".saboteur/", "Cache: coverage map and incremental verdicts. Safe to delete between runs; stop the run first."]
|
|
169
220
|
].freeze
|
|
170
221
|
|
|
171
222
|
# option_lines: the option lines of each command's parser, keyed by command name.
|
data/lib/saboteur/ignore_list.rb
CHANGED
|
@@ -21,7 +21,15 @@ module Saboteur
|
|
|
21
21
|
#
|
|
22
22
|
# reason is mandatory; a file with a missing or blank reason does not load.
|
|
23
23
|
class IgnoreList
|
|
24
|
-
|
|
24
|
+
# Triage states beyond plain ignoring (#155): accepted_equivalent and
|
|
25
|
+
# wont_fix suppress the survivor's exit failure (it still counts in the
|
|
26
|
+
# score), todo records the decision to fix and lets the survivor count.
|
|
27
|
+
STATES = %w[accepted_equivalent wont_fix todo].freeze
|
|
28
|
+
|
|
29
|
+
Entry = Data.define(:subject, :mutation, :reason, :state) do
|
|
30
|
+
# Entries built without a state predate triage states: they accept.
|
|
31
|
+
def initialize(subject:, mutation:, reason:, state: "accepted_equivalent") = super
|
|
32
|
+
end
|
|
25
33
|
|
|
26
34
|
# Collects the entries a saboteur_ignore.rb file declares.
|
|
27
35
|
class Collector
|
|
@@ -31,16 +39,23 @@ module Saboteur
|
|
|
31
39
|
@entries = []
|
|
32
40
|
end
|
|
33
41
|
|
|
34
|
-
def mutation(digest = nil, subject: nil, reason: nil)
|
|
42
|
+
def mutation(digest = nil, subject: nil, reason: nil, state: nil)
|
|
35
43
|
line = caller_locations(1, 1).first.lineno
|
|
36
44
|
values = { "subject" => subject, "mutation" => digest, "reason" => reason }
|
|
37
45
|
missing = values.select { |_, value| value.to_s.strip.empty? }.keys
|
|
38
46
|
raise ConfigError, "#{line}: entry is missing #{missing.join(', ')}" if missing.any?
|
|
39
47
|
|
|
48
|
+
state = state.to_s.strip
|
|
49
|
+
state = "accepted_equivalent" if state.empty?
|
|
50
|
+
unless STATES.include?(state)
|
|
51
|
+
raise ConfigError, "#{line}: unknown state #{state.inspect} - use one of #{STATES.join(', ')}"
|
|
52
|
+
end
|
|
53
|
+
|
|
40
54
|
@entries << Entry.new(
|
|
41
55
|
subject: subject.to_s.strip,
|
|
42
56
|
mutation: digest.to_s.strip,
|
|
43
|
-
reason: reason.to_s.strip
|
|
57
|
+
reason: reason.to_s.strip,
|
|
58
|
+
state: state
|
|
44
59
|
)
|
|
45
60
|
end
|
|
46
61
|
end
|
|
@@ -96,9 +111,13 @@ module Saboteur
|
|
|
96
111
|
@index = entries.to_h { |entry| [[entry.subject, entry.mutation], entry] }
|
|
97
112
|
end
|
|
98
113
|
|
|
99
|
-
# True when this result is a survivor the list accepts
|
|
114
|
+
# True when this result is a survivor the list accepts: a todo entry
|
|
115
|
+
# records the triage but keeps the survivor counting as unignored.
|
|
100
116
|
def accepts?(result)
|
|
101
|
-
|
|
117
|
+
return false unless result.survived?
|
|
118
|
+
|
|
119
|
+
entry = @index[[result.subject.id, result.digest]]
|
|
120
|
+
!entry.nil? && entry.state != "todo"
|
|
102
121
|
end
|
|
103
122
|
|
|
104
123
|
# Marks matching survivors as ignored. Returns warnings for stale entries:
|
|
@@ -114,7 +133,11 @@ module Saboteur
|
|
|
114
133
|
next unless entry
|
|
115
134
|
|
|
116
135
|
seen[key] = true
|
|
117
|
-
|
|
136
|
+
next unless result.survived?
|
|
137
|
+
|
|
138
|
+
result.triage_state = entry.state
|
|
139
|
+
result.triage_reason = entry.reason
|
|
140
|
+
result.ignored = entry.reason if entry.state != "todo"
|
|
118
141
|
end
|
|
119
142
|
stale_warnings(results, seen, unscoped_digests)
|
|
120
143
|
end
|