saboteur 0.7.0 → 0.8.1
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 +772 -0
- data/README.md +221 -1072
- data/lib/saboteur/attribute_finder.rb +113 -0
- 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 +47 -3
- data/lib/saboteur/cli/run_budget_options.rb +3 -1
- 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 +35 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +74 -55
- data/lib/saboteur/config.rb +102 -6
- data/lib/saboteur/config_file.rb +19 -7
- 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/custom_map.rb +12 -1
- data/lib/saboteur/dirty_tree_warning.rb +130 -0
- data/lib/saboteur/doctor.rb +13 -0
- data/lib/saboteur/engine/baseline_guard.rb +135 -21
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +20 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +58 -14
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +19 -1
- 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 +37 -0
- data/lib/saboteur/engine/load_coverage.rb +63 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +115 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +32 -2
- data/lib/saboteur/engine/sandbox_policy.rb +59 -3
- data/lib/saboteur/engine/scheduler.rb +131 -17
- data/lib/saboteur/engine/selection_announcement.rb +59 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +380 -21
- data/lib/saboteur/engine/test_guess.rb +407 -27
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +103 -129
- data/lib/saboteur/execution/external_runner.rb +489 -0
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/marker_sweep.rb +80 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +19 -3
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +75 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +25 -6
- data/lib/saboteur/execution/seatbelt.rb +233 -0
- data/lib/saboteur/execution/supervisor.rb +29 -11
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -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 +223 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +28 -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 +189 -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 +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -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 +125 -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 +31 -5
- 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 +56 -8
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +120 -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 +14 -0
- metadata +39 -16
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# The warning shown before a run on a tree with uncommitted
|
|
6
|
+
# changes (#309, #316); the headline says uncommitted or untracked, so a
|
|
7
|
+
# tree of only scratch files is not read as edits (#604). Mutants run your code and can change the tree, so the
|
|
8
|
+
# warning stays; it splits the raw count so a tree full of notes or trial
|
|
9
|
+
# setup files does not read like application edits about to be mutated.
|
|
10
|
+
#
|
|
11
|
+
# Each change lands in exactly one group, so the groups add up to the
|
|
12
|
+
# count: saboteur setup files first (the config file the run reads, a
|
|
13
|
+
# trial Gemfile and its lock), then the mutation scope (a file the
|
|
14
|
+
# configured include and exclude select, the way Config#included_files
|
|
15
|
+
# does), then the rest. A path the patterns match also counts, so a
|
|
16
|
+
# deleted source is still inside the scope. The scope is the configured
|
|
17
|
+
# one: --since, --subject and source operands narrow a run further, after
|
|
18
|
+
# this warning.
|
|
19
|
+
class DirtyTreeWarning
|
|
20
|
+
SHOWN_PATHS = 5
|
|
21
|
+
MATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
22
|
+
ADVICE = "Mutants run your code and could alter or delete files; " \
|
|
23
|
+
"run on a clean, committed tree or a CI checkout."
|
|
24
|
+
|
|
25
|
+
# The warning text for the run's working tree, or nil on a clean or
|
|
26
|
+
# unknown tree. The root is physical (realpath), like the paths
|
|
27
|
+
# Config#included_files returns.
|
|
28
|
+
def self.for(config, config_path:, gemfile: ENV.fetch("BUNDLE_GEMFILE", nil))
|
|
29
|
+
state = WorkingTree.new(config.root, ignored: config.output_paths).state
|
|
30
|
+
root = File.realpath(config.root)
|
|
31
|
+
new(state, included: relative(config.included_files, root), include: config["include"],
|
|
32
|
+
exclude: config["exclude"], root: root,
|
|
33
|
+
setup_files: setup_files(root, config_path: config_path, gemfile: gemfile)).to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Files a trial of saboteur adds, relative to the root: the config file
|
|
37
|
+
# the run reads and, when BUNDLE_GEMFILE names a Gemfile other than the
|
|
38
|
+
# root's own, that Gemfile and its lock. Each file is compared by its
|
|
39
|
+
# physical path, so a /var spelling of a /private/var root still
|
|
40
|
+
# matches (#503).
|
|
41
|
+
def self.setup_files(root, config_path:, gemfile:)
|
|
42
|
+
files = [physical(File.expand_path(config_path || Config::FILE, root))]
|
|
43
|
+
trial = gemfile && physical(File.expand_path(gemfile, root))
|
|
44
|
+
files.push(trial, lockfile_for(trial)) if trial && trial != File.join(root, "Gemfile")
|
|
45
|
+
relative(files, root)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Bundler's own rule, the same one preflight's execution inputs check
|
|
49
|
+
# against Bundler.default_lockfile: a gems.rb trial locks to gems.locked
|
|
50
|
+
# next to it; every other name gets ".lock" appended.
|
|
51
|
+
def self.lockfile_for(gemfile)
|
|
52
|
+
return File.join(File.dirname(gemfile), "gems.locked") if File.basename(gemfile) == "gems.rb"
|
|
53
|
+
|
|
54
|
+
"#{gemfile}.lock"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# git status paths are relative to the root, so the patterns are too:
|
|
58
|
+
# Dir.glob accepts a leading ./ or an absolute path under the root and
|
|
59
|
+
# File.fnmatch? does not (#503). A pattern outside the root stays
|
|
60
|
+
# absolute and matches no git path.
|
|
61
|
+
def self.relative_patterns(patterns, root)
|
|
62
|
+
patterns.map { |pattern| File.expand_path(pattern, root).delete_prefix(File.join(root, "")) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The path with its directory resolved, so a file that does not exist
|
|
66
|
+
# yet still gets the physical spelling of where it would be.
|
|
67
|
+
def self.physical(path)
|
|
68
|
+
File.join(File.realpath(File.dirname(path)), File.basename(path))
|
|
69
|
+
rescue SystemCallError
|
|
70
|
+
path
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A file outside the root keeps its absolute path, which no
|
|
74
|
+
# root-relative git path can equal.
|
|
75
|
+
def self.relative(files, root)
|
|
76
|
+
prefix = File.join(root, "")
|
|
77
|
+
files.map { |file| file.delete_prefix(prefix) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private_class_method :physical, :relative
|
|
81
|
+
|
|
82
|
+
def initialize(state, included:, include:, exclude:, setup_files:, root:)
|
|
83
|
+
@state = state
|
|
84
|
+
@included = included.to_set
|
|
85
|
+
@include = self.class.relative_patterns(include, root)
|
|
86
|
+
@exclude = self.class.relative_patterns(exclude, root)
|
|
87
|
+
@setup_files = setup_files
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_s
|
|
91
|
+
return unless @state.dirty
|
|
92
|
+
|
|
93
|
+
setup, rest = @state.changes.partition { |change| @setup_files.include?(change.path) }
|
|
94
|
+
scope, outside = rest.partition { |change| in_scope?(change.path) }
|
|
95
|
+
[summary(setup, scope, outside), named("inside the mutation scope", scope), named("setup files", setup),
|
|
96
|
+
" #{ADVICE}"].compact.join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def summary(setup, scope, outside)
|
|
102
|
+
groups = [scope.empty? ? "none inside the mutation scope" : counted("inside the mutation scope", scope),
|
|
103
|
+
counted("outside it", outside)]
|
|
104
|
+
groups << counted("saboteur setup file(s)", setup) unless setup.empty?
|
|
105
|
+
"saboteur: warning: #{@state.changed_files} uncommitted or untracked change(s) in the working tree: " \
|
|
106
|
+
"#{groups.join(', ')}."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def counted(label, changes)
|
|
110
|
+
tracked = changes.count(&:tracked)
|
|
111
|
+
"#{changes.size} #{label} (#{tracked} tracked, #{changes.size - tracked} untracked)"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def named(label, changes)
|
|
115
|
+
return if changes.empty?
|
|
116
|
+
|
|
117
|
+
paths = changes.first(SHOWN_PATHS).map(&:path).join(", ")
|
|
118
|
+
more = changes.size - SHOWN_PATHS
|
|
119
|
+
" #{label}: #{paths}#{" and #{more} more" if more.positive?}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def in_scope?(path)
|
|
123
|
+
@included.include?(path) || (path.end_with?(".rb") && matches?(@include, path) && !matches?(@exclude, path))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def matches?(patterns, path)
|
|
127
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, path, MATCH_FLAGS) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/saboteur/doctor.rb
CHANGED
|
@@ -98,6 +98,19 @@ module Saboteur
|
|
|
98
98
|
else
|
|
99
99
|
@out.puts "Sandbox: #{Engine::SandboxPolicy.sandbox_gap(abi)}; " \
|
|
100
100
|
"children would run with the hazard guard and canary only"
|
|
101
|
+
if RUBY_PLATFORM.match?(/darwin/i) && @config["sandbox"] == "seatbelt"
|
|
102
|
+
@out.puts %(Seatbelt (opt-in, #436): probe #{Execution::Seatbelt.probe} - capability probe only, ) \
|
|
103
|
+
"not verified isolation"
|
|
104
|
+
elsif RUBY_PLATFORM.match?(/darwin/i)
|
|
105
|
+
# Point at the way OUT of the degraded state, like the run
|
|
106
|
+
# warning does (#667): the opt-in Seatbelt sandbox, or silence.
|
|
107
|
+
@out.puts "macOS: mutants run unsandboxed (Landlock is Linux-only) - a container contains " \
|
|
108
|
+
"filesystem writes outside the project"
|
|
109
|
+
@out.puts %(macOS: opt into the sandbox with c.sandbox = "seatbelt" ) \
|
|
110
|
+
"(docs/guides/macos-seatbelt-sandbox.md), or set c.sandbox = false to silence this"
|
|
111
|
+
@out.puts "Network: never restricted on any platform, and a plain container keeps outbound " \
|
|
112
|
+
"access - deny or stub the network when a mutant could reach real APIs"
|
|
113
|
+
end
|
|
101
114
|
end
|
|
102
115
|
end
|
|
103
116
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require_relative "../text"
|
|
7
|
+
require_relative "../frameworks/sqlite_memory_database"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
9
10
|
class Engine
|
|
@@ -23,7 +24,7 @@ module Saboteur
|
|
|
23
24
|
# coverage build when the cache is cold, so the first timed pass can
|
|
24
25
|
# price the rest (#260).
|
|
25
26
|
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, full_passes: 2, between_passes: nil,
|
|
26
|
-
timeout_cap: nil)
|
|
27
|
+
timeout_cap: nil, run_started: nil)
|
|
27
28
|
@config = config
|
|
28
29
|
@integration = integration
|
|
29
30
|
@hooks = hooks
|
|
@@ -32,6 +33,7 @@ module Saboteur
|
|
|
32
33
|
@full_passes = full_passes
|
|
33
34
|
@between_passes = between_passes
|
|
34
35
|
@timeout_cap = timeout_cap
|
|
36
|
+
@run_started = run_started
|
|
35
37
|
@completed_passes = 0
|
|
36
38
|
end
|
|
37
39
|
|
|
@@ -50,10 +52,7 @@ module Saboteur
|
|
|
50
52
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
53
|
data = check(number)
|
|
52
54
|
announce_first_pass_cost(number, started)
|
|
53
|
-
unless data["passed"] && data["failed"].empty?
|
|
54
|
-
refuse(number,
|
|
55
|
-
"the suite failed: #{Array(data['failed']).join(', ')}")
|
|
56
|
-
end
|
|
55
|
+
refuse_suite_failure(number, data) unless data["passed"] && data["failed"].empty?
|
|
57
56
|
executed = data["executed"]
|
|
58
57
|
refuse(number, "executed tests do not match the loaded suite") unless
|
|
59
58
|
executed.sort == expected.sort
|
|
@@ -66,6 +65,16 @@ module Saboteur
|
|
|
66
65
|
|
|
67
66
|
private
|
|
68
67
|
|
|
68
|
+
def refuse_failed_suite(number, data)
|
|
69
|
+
outside = data["failed"].empty?
|
|
70
|
+
reason = if outside
|
|
71
|
+
"the suite errored outside any example; see the log tail"
|
|
72
|
+
else
|
|
73
|
+
"the suite failed: #{data['failed'].join(', ')}"
|
|
74
|
+
end
|
|
75
|
+
refuse(number, with_hint(reason, data), tail: outside)
|
|
76
|
+
end
|
|
77
|
+
|
|
69
78
|
# A spent --max-duration budget skips the remaining passes - including
|
|
70
79
|
# the first, when boot alone already spent it (#245).
|
|
71
80
|
def budget_spent?(_index)
|
|
@@ -85,16 +94,47 @@ module Saboteur
|
|
|
85
94
|
end
|
|
86
95
|
|
|
87
96
|
# Once the first pass is timed, the total cost stops being a guess:
|
|
88
|
-
# the remaining full passes cost roughly the same each.
|
|
97
|
+
# the remaining full passes cost roughly the same each. full_passes
|
|
98
|
+
# includes the cold-cache coverage build (#260), so name what actually
|
|
99
|
+
# remains: with baseline_passes 1 the one remaining pass is the coverage
|
|
100
|
+
# build, not another baseline check (#465).
|
|
89
101
|
def announce_first_pass_cost(number, started)
|
|
90
102
|
return unless number == 1
|
|
91
103
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
coverage_pending = @full_passes > @config["baseline_passes"]
|
|
105
|
+
remaining = @full_passes - number - (coverage_pending ? 1 : 0)
|
|
95
106
|
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
warn_budget_projection(elapsed, remaining + (coverage_pending ? 1 : 0))
|
|
108
|
+
return if remaining.zero? && !coverage_pending
|
|
109
|
+
|
|
110
|
+
work = []
|
|
111
|
+
work << "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'}" if remaining.positive?
|
|
112
|
+
work << "the coverage map build" if coverage_pending
|
|
113
|
+
total = remaining + (coverage_pending ? 1 : 0)
|
|
114
|
+
@diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
|
|
115
|
+
"#{work.join(' and ')} at that rate")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The first timed pass also prices the run against --max-duration:
|
|
119
|
+
# boot and this pass are spent, the remaining passes and a cold
|
|
120
|
+
# coverage build cost about that much again at that rate, and only
|
|
121
|
+
# what is left feeds mutants. Warn while a Ctrl-C is still cheap -
|
|
122
|
+
# the refusal only fires once the coverage build is unaffordable
|
|
123
|
+
# outright (#637).
|
|
124
|
+
def warn_budget_projection(pass_elapsed, remaining_passes)
|
|
125
|
+
budget = @config["max_duration"]
|
|
126
|
+
return unless budget && @run_started
|
|
127
|
+
|
|
128
|
+
spent = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started
|
|
129
|
+
projected = spent + (pass_elapsed * remaining_passes)
|
|
130
|
+
mutants_get = budget - projected
|
|
131
|
+
return if mutants_get >= pass_elapsed
|
|
132
|
+
|
|
133
|
+
verdict = mutants_get <= 0 ? "exceeds" : "barely fits"
|
|
134
|
+
@diagnostics.warn("projected baseline+coverage ~#{projected.round}s #{verdict} " \
|
|
135
|
+
"--max-duration #{budget.round}s; mutants would get " \
|
|
136
|
+
"~#{format('%g', [mutants_get, 0].max.round(1))}s. Raise --max-duration, pass fewer " \
|
|
137
|
+
"--baseline-passes, or narrow the scope")
|
|
98
138
|
end
|
|
99
139
|
|
|
100
140
|
# An admitted pass still gets only the time left in the run budget:
|
|
@@ -117,6 +157,24 @@ module Saboteur
|
|
|
117
157
|
"phase timeout)"
|
|
118
158
|
end
|
|
119
159
|
|
|
160
|
+
# A containment limit stopping a healthy suite means the limit, not
|
|
161
|
+
# the suite: name the knob and its current value, like the sqlite
|
|
162
|
+
# :memory: hint (#387), so the user is not sent debugging a green
|
|
163
|
+
# suite (#459).
|
|
164
|
+
def stopped_reason(result)
|
|
165
|
+
reason = "the suite stopped: #{result.stopped_by} (#{result.detail})"
|
|
166
|
+
case result.stopped_by
|
|
167
|
+
when :processes
|
|
168
|
+
"#{reason}. If this suite forks heavily by design, raise the limit with " \
|
|
169
|
+
"c.max_processes (currently #{@config['max_processes']})"
|
|
170
|
+
when :memory
|
|
171
|
+
"#{reason}. If this suite legitimately needs more memory, raise the limit with " \
|
|
172
|
+
"c.max_memory_mb (currently #{@config['max_memory_mb']} MB)"
|
|
173
|
+
else
|
|
174
|
+
reason
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
120
178
|
def check(number)
|
|
121
179
|
Execution::ForkReadiness.prepare_heap
|
|
122
180
|
heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
|
|
@@ -126,14 +184,14 @@ module Saboteur
|
|
|
126
184
|
record(writer, number)
|
|
127
185
|
end
|
|
128
186
|
refuse_budget_stop(number, admitted) if budget_stopped?(result, admitted)
|
|
129
|
-
refuse(number,
|
|
187
|
+
refuse(number, stopped_reason(result)) if result.stopped_by
|
|
130
188
|
# Before the payload checks: a child killed by a signal (the OOM
|
|
131
189
|
# killer, say) writes nothing, and the signal is the explanation. A
|
|
132
190
|
# child the supervisor itself killed after it had reported is not a
|
|
133
191
|
# failure: its payload is checked below.
|
|
134
192
|
refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
|
|
135
193
|
refuse(number, "the child did not report a result") if result.payload.empty?
|
|
136
|
-
refuse(number,
|
|
194
|
+
refuse(number, leftover_message(result), excerpt: false) if result.leftover_pids.any?
|
|
137
195
|
parse_result(number, result.payload)
|
|
138
196
|
end
|
|
139
197
|
|
|
@@ -151,7 +209,7 @@ module Saboteur
|
|
|
151
209
|
def parse_result(number, payload)
|
|
152
210
|
data = JSON.parse(payload)
|
|
153
211
|
refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
|
|
154
|
-
refuse(number, data["error"]) if data["error"]
|
|
212
|
+
refuse(number, with_hint(data["error"], data)) if data["error"]
|
|
155
213
|
refuse(number, "the child reported invalid test results") unless valid_result?(data)
|
|
156
214
|
data
|
|
157
215
|
rescue JSON::ParserError
|
|
@@ -172,31 +230,87 @@ module Saboteur
|
|
|
172
230
|
@hooks.run(:after_fork)
|
|
173
231
|
Kernel.srand(@config["seed"])
|
|
174
232
|
result = @integration.run_baseline(seed: @config["seed"])
|
|
175
|
-
|
|
233
|
+
payload = { passed: result.passed, failed: result.failed_ids, executed: result.executed_ids }
|
|
234
|
+
payload[:hint] = Frameworks::SqliteMemoryDatabase.fork_loss_hint unless result.passed
|
|
235
|
+
writer.write(JSON.generate(payload))
|
|
176
236
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
177
|
-
writer.write(JSON.generate(error: "#{e.class}: #{e.message}"
|
|
237
|
+
writer.write(JSON.generate(error: "#{e.class}: #{e.message}",
|
|
238
|
+
hint: Frameworks::SqliteMemoryDatabase.fork_loss_hint))
|
|
178
239
|
ensure
|
|
179
240
|
writer.close
|
|
180
241
|
end
|
|
181
242
|
|
|
243
|
+
# A failed unchanged suite never runs mutants: warn first when the
|
|
244
|
+
# failure carries the Minitest signature (#626), then refuse.
|
|
245
|
+
def refuse_suite_failure(number, data)
|
|
246
|
+
warn_minitest_signature(number)
|
|
247
|
+
refuse_failed_suite(number, data)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Minitest::Spec suites in *_spec.rb clothing load under the RSpec
|
|
251
|
+
# integration and then fail the baseline with NoMethodError for
|
|
252
|
+
# Minitest-only methods (#626): the refusal still reads "fix the
|
|
253
|
+
# suite", so warn what the signature actually points at. It stays a
|
|
254
|
+
# warning: an RSpec suite can require minitest for a helper and
|
|
255
|
+
# fail for its own reason, and a heuristic refusal would
|
|
256
|
+
# false-positive there.
|
|
257
|
+
def warn_minitest_signature(number)
|
|
258
|
+
lines = log_lines(number)
|
|
259
|
+
errors = lines.grep(/NoMethodError/)
|
|
260
|
+
return if errors.empty? || !spec_helper_requires_minitest?
|
|
261
|
+
|
|
262
|
+
symptom = (lines.find { |line| line.include?("undefined method") } || errors.first).strip
|
|
263
|
+
@diagnostics.warn("the baseline failure looks like a Minitest suite running under RSpec, not a " \
|
|
264
|
+
"broken RSpec suite: the log shows #{errors.size} " \
|
|
265
|
+
"NoMethodError#{'s' if errors.size > 1} (for example: #{symptom.slice(0, EXCERPT_WIDTH)}) " \
|
|
266
|
+
"and spec/spec_helper.rb requires minitest. saboteur is RSpec-only, so a " \
|
|
267
|
+
"Minitest suite cannot be mutation tested with it.")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The framework check behind the warning: spec_helper is the
|
|
271
|
+
# suite's own declaration of what it runs on.
|
|
272
|
+
def spec_helper_requires_minitest?
|
|
273
|
+
File.read(File.join(@config.root, "spec", "spec_helper.rb")).match?(/^\s*require\s+["']minitest/)
|
|
274
|
+
rescue SystemCallError
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# A child that recognises its own failure shape (an empty sqlite
|
|
279
|
+
# :memory: database after the fork, #387) says how to fix it.
|
|
280
|
+
def with_hint(reason, data)
|
|
281
|
+
data["hint"] ? "#{reason}. #{data['hint']}" : reason
|
|
282
|
+
end
|
|
283
|
+
|
|
182
284
|
def log_path(number)
|
|
183
285
|
File.join(@config.path("cache_dir"), "baseline-#{number}.log")
|
|
184
286
|
end
|
|
185
287
|
|
|
186
|
-
|
|
288
|
+
# Leftover children are a pass-with-leaks verdict, not a suite
|
|
289
|
+
# failure: the message names the leaked processes (the supervisor's
|
|
290
|
+
# pre-kill notes) and skips the log excerpt, which holds only RSpec
|
|
291
|
+
# output and can never show them (#486).
|
|
292
|
+
def leftover_message(result)
|
|
293
|
+
notes = result.leftover_notes
|
|
294
|
+
detail = notes.empty? ? result.leftover_pids.join(", ") : notes.join(", ")
|
|
295
|
+
count = result.leftover_pids.size
|
|
296
|
+
"the suite passed but left #{count} child #{count == 1 ? 'process' : 'processes'} behind: #{detail}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def refuse(number, reason, excerpt: true, tail: false)
|
|
187
300
|
raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
|
|
188
|
-
"#{failure_excerpt(number)}See the full log at #{log_path(number)}.
|
|
189
|
-
"setup, then run saboteur again with the same seed.
|
|
301
|
+
"#{failure_excerpt(number, tail: tail) if excerpt}See the full log at #{log_path(number)}. " \
|
|
302
|
+
"Fix the suite or its setup, then run saboteur again with the same seed. " \
|
|
303
|
+
"No mutants were started."
|
|
190
304
|
end
|
|
191
305
|
|
|
192
306
|
# The first lines of the failure, inline, so the cause is readable
|
|
193
307
|
# without opening the log: RSpec's "Failures:" section when the log has
|
|
194
308
|
# one, otherwise the last lines of the log.
|
|
195
|
-
def failure_excerpt(number)
|
|
309
|
+
def failure_excerpt(number, tail: false)
|
|
196
310
|
lines = log_lines(number)
|
|
197
311
|
return "" if lines.empty?
|
|
198
312
|
|
|
199
|
-
start = lines.index { |line| line.start_with?("Failures:") }
|
|
313
|
+
start = lines.index { |line| line.start_with?("Failures:") } unless tail
|
|
200
314
|
shown = start ? lines[start, EXCERPT_LINES] : lines.last(EXCERPT_LINES)
|
|
201
315
|
"The log says:\n#{shown.map { |line| " #{line.slice(0, EXCERPT_WIDTH)}" }.join("\n")}\n"
|
|
202
316
|
end
|
data/lib/saboteur/engine/boot.rb
CHANGED
|
@@ -10,6 +10,10 @@ module Saboteur
|
|
|
10
10
|
# A test suite that (perhaps only under a mutant) starts a saboteur run
|
|
11
11
|
# would otherwise run saboteur inside a saboteur child, recursively.
|
|
12
12
|
def self.refuse_recursion!
|
|
13
|
+
if ENV[Execution::ExternalRunner::CHILD_MARKER]
|
|
14
|
+
raise Error, "refusing to start: this process runs inside a saboteur external test command " \
|
|
15
|
+
"(#{Execution::ExternalRunner::CHILD_MARKER} is set); a nested run could race its tree"
|
|
16
|
+
end
|
|
13
17
|
return unless ENV[Execution::Supervisor::CHILD_ENV]
|
|
14
18
|
|
|
15
19
|
raise Error, "refusing to start: this process is a saboteur child " \
|
|
@@ -62,7 +66,9 @@ module Saboteur
|
|
|
62
66
|
@rails = rails = Frameworks::Rails.new(@config, @hooks) if @config["rails"]
|
|
63
67
|
rails&.before_boot
|
|
64
68
|
@hooks.run(:before_boot)
|
|
65
|
-
add_spec_to_load_path
|
|
69
|
+
add_spec_to_load_path
|
|
70
|
+
# Helpers may configure RSpec before the integration loads specs.
|
|
71
|
+
Integration::RSpec.new(@config).require_framework if @config["integration"] == "rspec"
|
|
66
72
|
@config["require"].each { |file| require File.expand_path(file, @root) }
|
|
67
73
|
integration = Integration.fetch(@config["integration"]).new(@config)
|
|
68
74
|
integration.setup
|
|
@@ -74,8 +80,10 @@ module Saboteur
|
|
|
74
80
|
private
|
|
75
81
|
|
|
76
82
|
# rspec puts spec/ on the load path, so a boot file under spec/ (a
|
|
77
|
-
# dummy app's rails_helper,
|
|
78
|
-
#
|
|
83
|
+
# dummy app's rails_helper, or a non-Rails gem's spec_helper that
|
|
84
|
+
# requires its siblings) can require its siblings. Match that for
|
|
85
|
+
# every project - a non-Rails gem does not add spec/ itself (#615) -
|
|
86
|
+
# or the same require list fails to load.
|
|
79
87
|
def add_spec_to_load_path
|
|
80
88
|
path = File.join(@root, "spec")
|
|
81
89
|
$LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
|
|
@@ -42,11 +42,27 @@ module Saboteur
|
|
|
42
42
|
|
|
43
43
|
def fingerprint(text, frozen)
|
|
44
44
|
options = { frozen_string_literal: frozen }
|
|
45
|
-
|
|
45
|
+
iseq = warnings_silenced do
|
|
46
|
+
RubyVM::InstructionSequence.compile(text, "saboteur/bytecode-equivalence", nil, 1, options)
|
|
47
|
+
end
|
|
48
|
+
strip_positions(iseq.to_a)
|
|
46
49
|
rescue SyntaxError, StandardError
|
|
47
50
|
nil
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
# Mutants compile one by one, and generated bodies (body_nil,
|
|
54
|
+
# statement_removal) trip Ruby's own void-context warnings, so a host
|
|
55
|
+
# suite running with -w gets one line per mutant flooding stderr
|
|
56
|
+
# (#592, #656). The check is internal: silence the compiler. A mutant
|
|
57
|
+
# that does not parse still fails the compile and runs.
|
|
58
|
+
def warnings_silenced
|
|
59
|
+
saved = $VERBOSE
|
|
60
|
+
$VERBOSE = nil
|
|
61
|
+
yield
|
|
62
|
+
ensure
|
|
63
|
+
$VERBOSE = saved
|
|
64
|
+
end
|
|
65
|
+
|
|
50
66
|
# Structural equality that never coerces: an Integer is not a Float,
|
|
51
67
|
# and +0.0 is not -0.0 (1.0 / +0.0 and 1.0 / -0.0 are different
|
|
52
68
|
# infinities), which eql? alone would not tell apart.
|
|
@@ -22,6 +22,10 @@ module Saboteur
|
|
|
22
22
|
reason = subject.skip_reason || (skip_reason_for(subject) if @map.tests_for(subject).empty?)
|
|
23
23
|
if reason
|
|
24
24
|
@skipped_subjects[subject] = reason
|
|
25
|
+
# In a scoped run every subject is part of the change under
|
|
26
|
+
# review, so a skip is said up front, not only in the end-of-run
|
|
27
|
+
# summary (#574).
|
|
28
|
+
@diagnostics.announce(skip_announcement(subject, reason)) if scope
|
|
25
29
|
return @selected[subject] = []
|
|
26
30
|
end
|
|
27
31
|
|
|
@@ -44,9 +48,25 @@ module Saboteur
|
|
|
44
48
|
|
|
45
49
|
private
|
|
46
50
|
|
|
51
|
+
# A scoped no-coverage skip names the specs the run loaded: coverage
|
|
52
|
+
# living in a sibling spec file is otherwise invisible, and the false
|
|
53
|
+
# read is "untestable" when the truth is "not loaded" (#636). Names
|
|
54
|
+
# at most five, like the other selection notes.
|
|
55
|
+
def skip_announcement(subject, reason)
|
|
56
|
+
line = "#{subject.id}: skipped (#{reason}); its change is not verified by this run"
|
|
57
|
+
return line unless reason == "no coverage"
|
|
58
|
+
|
|
59
|
+
loaded = @map.tests.map { |id| id.split("[").first.delete_prefix("./") }.uniq.sort
|
|
60
|
+
more = loaded.size > 5 ? ", ..." : ""
|
|
61
|
+
names = loaded.empty? ? "none" : loaded.first(5).join(", ")
|
|
62
|
+
"#{line} - loaded specs: #{names}#{more}; " \
|
|
63
|
+
"other spec files may cover it, pass --test to load them"
|
|
64
|
+
end
|
|
65
|
+
|
|
47
66
|
# Load-only code is not "no coverage": it ran, just never inside a
|
|
48
67
|
# test, and no forked test can re-run it (see CoverageMap::RAN_AT_LOAD).
|
|
49
68
|
def skip_reason_for(subject)
|
|
69
|
+
return Selection::CoverageMap::PRELOADED if @map.preloaded?(subject.file)
|
|
50
70
|
return "no coverage" unless @map.ran_at_load?(subject.file, subject.coverage_lines)
|
|
51
71
|
|
|
52
72
|
Selection::CoverageMap::RAN_AT_LOAD
|
|
@@ -10,6 +10,25 @@ module Saboteur
|
|
|
10
10
|
# a subject into runnable candidates: applied, re-parsed, de-duplicated,
|
|
11
11
|
# coarse first. Returns [[mutation, mutated_text]].
|
|
12
12
|
class Candidates
|
|
13
|
+
# The generator for constant hash/array literal subjects (#306); the
|
|
14
|
+
# method operators walk a def body a constant does not have.
|
|
15
|
+
def self.build(config, diagnostics, hazard_guard:)
|
|
16
|
+
new(build_sources(config), diagnostics, hazard_guard: hazard_guard,
|
|
17
|
+
constant_source: build_constant_source(config))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Constants are generated by the builtin operator family, so a
|
|
21
|
+
# config that drops "builtin" from c.sources drops constant mutation
|
|
22
|
+
# too: the configured sources are the boundary for every subject
|
|
23
|
+
# kind (#319). Custom sources never see constants.
|
|
24
|
+
def self.build_constant_source(config)
|
|
25
|
+
return nil unless config["sources"].include?("builtin")
|
|
26
|
+
|
|
27
|
+
MutationSource::ConstantLiterals.new(operators: config["operators"],
|
|
28
|
+
exclude_operators: config["exclude_operators"],
|
|
29
|
+
arid: config["arid"])
|
|
30
|
+
end
|
|
31
|
+
|
|
13
32
|
def self.build_sources(config)
|
|
14
33
|
root = File.realpath(config.root)
|
|
15
34
|
config["sources"].map do |entry|
|
|
@@ -19,15 +38,26 @@ module Saboteur
|
|
|
19
38
|
end
|
|
20
39
|
|
|
21
40
|
file, const = entry.split(":", 2)
|
|
22
|
-
|
|
41
|
+
unless const
|
|
42
|
+
if entry.include?("*")
|
|
43
|
+
raise ConfigError, "mutation source #{entry.inspect} looks like a source glob; " \
|
|
44
|
+
"did you mean c.include? c.sources registers custom operator sources " \
|
|
45
|
+
"(builtin or path.rb:ClassName)"
|
|
46
|
+
end
|
|
47
|
+
raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName"
|
|
48
|
+
end
|
|
23
49
|
|
|
24
50
|
require File.expand_path(file, root)
|
|
25
51
|
Object.const_get(const).new
|
|
26
52
|
end
|
|
27
53
|
end
|
|
28
54
|
|
|
29
|
-
|
|
55
|
+
# constant_source: the generator for ConstantSubject instances; nil
|
|
56
|
+
# leaves constants with no candidates (a safe empty list).
|
|
57
|
+
def initialize(sources, diagnostics, hazard_guard: nil, bytecode: BytecodeEquivalence.new,
|
|
58
|
+
constant_source: nil)
|
|
30
59
|
@sources = sources
|
|
60
|
+
@constant_source = constant_source
|
|
31
61
|
@diagnostics = diagnostics
|
|
32
62
|
@hazard_guard = hazard_guard
|
|
33
63
|
@bytecode = bytecode
|
|
@@ -40,7 +70,7 @@ module Saboteur
|
|
|
40
70
|
end
|
|
41
71
|
|
|
42
72
|
seen = Set[subject.source]
|
|
43
|
-
list =
|
|
73
|
+
list = sources_for(subject).flat_map { |source| proposals(source, subject) }.filter_map do |mutation|
|
|
44
74
|
candidate(subject, mutation, seen)
|
|
45
75
|
end
|
|
46
76
|
list = suppress_inline(list, subject)
|
|
@@ -50,6 +80,15 @@ module Saboteur
|
|
|
50
80
|
|
|
51
81
|
private
|
|
52
82
|
|
|
83
|
+
# Constant literals have their own generator; external custom sources
|
|
84
|
+
# implement the def-subject contract and never see constants (#306).
|
|
85
|
+
def sources_for(subject)
|
|
86
|
+
return @sources unless subject.is_a?(ConstantSubject)
|
|
87
|
+
return [] unless @constant_source
|
|
88
|
+
|
|
89
|
+
[@constant_source]
|
|
90
|
+
end
|
|
91
|
+
|
|
53
92
|
def suppress_inline(list, subject)
|
|
54
93
|
directives = InlineSuppression.new(subject.file, subject.file_source)
|
|
55
94
|
list.reject do |mutation, _text|
|
|
@@ -95,6 +134,7 @@ module Saboteur
|
|
|
95
134
|
seen << text
|
|
96
135
|
unless parses?(text)
|
|
97
136
|
@diagnostics.count_invalid_mutation
|
|
137
|
+
@diagnostics.warn("#{subject.id}: dropped #{mutation.operator} mutation (did not parse)")
|
|
98
138
|
return record_drop(subject, mutation, "invalid_syntax")
|
|
99
139
|
end
|
|
100
140
|
|