saboteur 0.4.0 → 0.6.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 +199 -0
- data/README.md +427 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +106 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +124 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +113 -122
- data/lib/saboteur/config.rb +96 -6
- data/lib/saboteur/doctor.rb +64 -9
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +62 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +7 -5
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +89 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +201 -19
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +32 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +115 -0
- data/lib/saboteur/reporter/agent_commands.rb +19 -5
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +12 -6
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +131 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +135 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +18 -3
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +140 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +44 -6
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +46 -2
|
@@ -10,6 +10,10 @@ module Saboteur
|
|
|
10
10
|
# or mutation source is used. This catches observed instability, not all
|
|
11
11
|
# possible flaky behaviour.
|
|
12
12
|
class BaselineGuard
|
|
13
|
+
EXCERPT_LINES = 10
|
|
14
|
+
EXCERPT_WIDTH = 200
|
|
15
|
+
EXCERPT_SCAN_LINES = 5000
|
|
16
|
+
|
|
13
17
|
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
|
|
14
18
|
@config = config
|
|
15
19
|
@integration = integration
|
|
@@ -45,29 +49,29 @@ module Saboteur
|
|
|
45
49
|
|
|
46
50
|
def check(number)
|
|
47
51
|
Execution::ForkReadiness.prepare_heap
|
|
48
|
-
heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}")
|
|
52
|
+
heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
|
|
49
53
|
result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
|
|
50
54
|
record(writer, number)
|
|
51
55
|
end
|
|
52
56
|
refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
|
|
53
57
|
# Before the payload checks: a child killed by a signal (the OOM
|
|
54
|
-
# killer, say) writes nothing, and the signal is the explanation.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
# killer, say) writes nothing, and the signal is the explanation. A
|
|
59
|
+
# child the supervisor itself killed after it had reported is not a
|
|
60
|
+
# failure: its payload is checked below.
|
|
61
|
+
refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
|
|
58
62
|
refuse(number, "the child did not report a result") if result.payload.empty?
|
|
59
63
|
refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
|
|
60
64
|
parse_result(number, result.payload)
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
# A signaled child (the OOM killer, say) can leave an empty log,
|
|
64
|
-
# which reads like a suite failure; name the signal
|
|
65
|
-
#
|
|
66
|
-
def unsuccessful_exit(status)
|
|
68
|
+
# which reads like a suite failure; name the signal. The OOM hint is
|
|
69
|
+
# printed only when the supervisor saw the process tree's memory grow.
|
|
70
|
+
def unsuccessful_exit(status, memory_pressure)
|
|
67
71
|
return "the child exited with status #{status.exitstatus}" unless status.signaled?
|
|
68
72
|
|
|
69
73
|
signal = "SIG#{Signal.signame(status.termsig)}"
|
|
70
|
-
hint = signal == "SIGKILL" ? " - possible OOM, check memory" : ""
|
|
74
|
+
hint = signal == "SIGKILL" && memory_pressure ? " - possible OOM, check memory" : ""
|
|
71
75
|
"the child was killed by #{signal}#{hint}"
|
|
72
76
|
end
|
|
73
77
|
|
|
@@ -108,8 +112,26 @@ module Saboteur
|
|
|
108
112
|
|
|
109
113
|
def refuse(number, reason)
|
|
110
114
|
raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
|
|
111
|
-
"See the log at #{log_path(number)}. Fix the suite or its
|
|
112
|
-
"with the same seed. No mutants were started."
|
|
115
|
+
"#{failure_excerpt(number)}See the full log at #{log_path(number)}. Fix the suite or its " \
|
|
116
|
+
"setup, then run saboteur again with the same seed. No mutants were started."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The first lines of the failure, inline, so the cause is readable
|
|
120
|
+
# without opening the log: RSpec's "Failures:" section when the log has
|
|
121
|
+
# one, otherwise the last lines of the log.
|
|
122
|
+
def failure_excerpt(number)
|
|
123
|
+
lines = log_lines(number)
|
|
124
|
+
return "" if lines.empty?
|
|
125
|
+
|
|
126
|
+
start = lines.index { |line| line.start_with?("Failures:") }
|
|
127
|
+
shown = start ? lines[start, EXCERPT_LINES] : lines.last(EXCERPT_LINES)
|
|
128
|
+
"The log says:\n#{shown.map { |line| " #{line.slice(0, EXCERPT_WIDTH)}" }.join("\n")}\n"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def log_lines(number)
|
|
132
|
+
File.foreach(log_path(number), chomp: true).first(EXCERPT_SCAN_LINES).reject { |line| line.strip.empty? }
|
|
133
|
+
rescue SystemCallError
|
|
134
|
+
[]
|
|
113
135
|
end
|
|
114
136
|
end
|
|
115
137
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Trivial compiler equivalence: a mutant whose method compiles to the
|
|
7
|
+
# same instruction sequence as the original cannot change behaviour, so
|
|
8
|
+
# running it can only produce a false survivor. The comparison walks the
|
|
9
|
+
# compiled instruction tree and drops only position metadata (line
|
|
10
|
+
# numbers, line events, source spans, node ids, file paths). Operands,
|
|
11
|
+
# including string and numeric literals, are compared exactly (2 and 2.0 differ, and so do +0.0 and -0.0).
|
|
12
|
+
class BytecodeEquivalence
|
|
13
|
+
ISEQ_MAGIC = "YARVInstructionSequence/SimpleDataFormat"
|
|
14
|
+
# Operand types whose eql? is exact. Anything else (an unknown object,
|
|
15
|
+
# a type that could hold a Float) is never called equal, so its
|
|
16
|
+
# mutant still runs.
|
|
17
|
+
PLAIN = [Integer, String, Symbol, Rational, Regexp, NilClass, TrueClass, FalseClass].freeze
|
|
18
|
+
POSITION_EVENT = /\ARUBY_EVENT_/
|
|
19
|
+
|
|
20
|
+
# True when mutated_text compiles to the same instructions as the
|
|
21
|
+
# original subject source under the same compile options. Anything the
|
|
22
|
+
# compiler cannot judge counts as not equivalent, so the mutant still
|
|
23
|
+
# runs. frozen_string_literal: whether the file carries that magic
|
|
24
|
+
# comment, which changes how string literals compile.
|
|
25
|
+
def equivalent?(original_text, mutated_text, frozen_string_literal: false)
|
|
26
|
+
original = fingerprint_of_original(original_text, frozen_string_literal)
|
|
27
|
+
return false unless original
|
|
28
|
+
|
|
29
|
+
exact?(original, fingerprint(mutated_text, frozen_string_literal))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Mutants of one method arrive together, so the last original is enough.
|
|
35
|
+
def fingerprint_of_original(text, frozen)
|
|
36
|
+
key = [text, frozen]
|
|
37
|
+
return @original_fingerprint if key == @original_key
|
|
38
|
+
|
|
39
|
+
@original_key = key
|
|
40
|
+
@original_fingerprint = fingerprint(text, frozen)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fingerprint(text, frozen)
|
|
44
|
+
options = { frozen_string_literal: frozen }
|
|
45
|
+
strip_positions(RubyVM::InstructionSequence.compile(text, nil, nil, 1, options).to_a)
|
|
46
|
+
rescue SyntaxError, StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Structural equality that never coerces: an Integer is not a Float,
|
|
51
|
+
# and +0.0 is not -0.0 (1.0 / +0.0 and 1.0 / -0.0 are different
|
|
52
|
+
# infinities), which eql? alone would not tell apart.
|
|
53
|
+
def exact?(left, right)
|
|
54
|
+
case left
|
|
55
|
+
when Array then right.is_a?(Array) && left.size == right.size && left.zip(right).all? { |l, r| exact?(l, r) }
|
|
56
|
+
when Hash, Complex, Range then right.instance_of?(left.class) && exact?(parts(left), parts(right))
|
|
57
|
+
when Float then right.is_a?(Float) && [left].pack("G") == [right].pack("G")
|
|
58
|
+
when *PLAIN then left.eql?(right)
|
|
59
|
+
else false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The values a composite operand is made of, compared one by one.
|
|
64
|
+
def parts(value)
|
|
65
|
+
case value
|
|
66
|
+
when Hash then value.to_a
|
|
67
|
+
when Complex then value.rectangular
|
|
68
|
+
else [value.begin, value.end, value.exclude_end?]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def strip_positions(node)
|
|
73
|
+
return node unless node.is_a?(Array)
|
|
74
|
+
return node.map { |child| strip_positions(child) } unless node.first == ISEQ_MAGIC
|
|
75
|
+
|
|
76
|
+
_magic, major, minor, format, _misc, name, _path, _real_path, _first_line, type, locals, params, catches,
|
|
77
|
+
body = node
|
|
78
|
+
[major, minor, format, name, type, locals, strip_positions(params), strip_positions(catches),
|
|
79
|
+
strip_body(body)]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def strip_body(body)
|
|
83
|
+
body.reject { |entry| position?(entry) }.map { |entry| strip_positions(entry) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Line numbers and line/call/return events depend only on layout.
|
|
87
|
+
def position?(entry)
|
|
88
|
+
case entry
|
|
89
|
+
when Integer, POSITION_EVENT then true
|
|
90
|
+
else false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class Engine
|
|
8
|
+
# Watches the working tree while mutants run (issue #102). A mutant that
|
|
9
|
+
# slips past the hazard guard and deletes tracked files must abort the
|
|
10
|
+
# run, not be reported as a survivor. The snapshot is the tracked files
|
|
11
|
+
# (git ls-files) when the tree is a repository, else every file under the
|
|
12
|
+
# root; the tool's own cache directory is excluded in both cases, and the
|
|
13
|
+
# presence of .git is watched separately.
|
|
14
|
+
class Canary
|
|
15
|
+
GIT_DIR = ".git"
|
|
16
|
+
|
|
17
|
+
def initialize(root, cache_dir: ".saboteur")
|
|
18
|
+
@root = root
|
|
19
|
+
@cache_dir = cache_dir.to_s
|
|
20
|
+
@snapshot = inventory
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# After a subject's mutants: any file present at snapshot time and gone
|
|
24
|
+
# now means a mutant deleted it. digest names the most recent mutant,
|
|
25
|
+
# the likely cause.
|
|
26
|
+
def check!(subject: nil, digest: nil)
|
|
27
|
+
missing = @snapshot - inventory
|
|
28
|
+
return if missing.empty?
|
|
29
|
+
|
|
30
|
+
of = " of #{subject.id}" if subject
|
|
31
|
+
culprit = digest ? " The most recent mutant, #{digest}#{of}, is the likely cause." : ""
|
|
32
|
+
raise Saboteur::CanaryTripped,
|
|
33
|
+
"canary: #{missing.size} watched file(s) vanished from the working tree while mutants ran " \
|
|
34
|
+
"(first: #{missing.first}).#{culprit} The canary watches tracked files and .git presence " \
|
|
35
|
+
"and only detects the loss after the fact; it cannot restore anything. Recover from your " \
|
|
36
|
+
"own backup, a fresh checkout, or a reclone - a deleted .git or uncommitted work is gone " \
|
|
37
|
+
"unless you have another copy."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def inventory
|
|
43
|
+
files = git? ? git_tracked_files : walk_files
|
|
44
|
+
files << GIT_DIR if File.exist?(File.join(@root, GIT_DIR))
|
|
45
|
+
files.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def git?
|
|
49
|
+
File.directory?(File.join(@root, GIT_DIR)) && system("git", "--version", %i[out err] => File::NULL)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def git_tracked_files
|
|
53
|
+
out, status = Open3.capture2("git", "ls-files", "-z", chdir: @root)
|
|
54
|
+
return walk_files unless status.success?
|
|
55
|
+
|
|
56
|
+
# ls-files reads the index, which still names a deleted file; keep
|
|
57
|
+
# only paths present in the worktree so a deletion shows up.
|
|
58
|
+
out.split("\0").reject { |rel| cache_path?(rel) }
|
|
59
|
+
.select { |rel| File.exist?(File.join(@root, rel)) || File.symlink?(File.join(@root, rel)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def walk_files
|
|
63
|
+
Dir.glob(File.join(@root, "**", "*"), File::FNM_DOTMATCH)
|
|
64
|
+
.select { |path| File.file?(path) }
|
|
65
|
+
.map { |path| path.delete_prefix("#{@root}/") }
|
|
66
|
+
.reject { |rel| rel.start_with?("#{GIT_DIR}/") || cache_path?(rel) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cache_path?(rel)
|
|
70
|
+
rel.start_with?("#{@cache_dir}/")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -6,7 +6,7 @@ module Saboteur
|
|
|
6
6
|
# Selects a bounded list for each covered subject before workers fork.
|
|
7
7
|
# Subject skips are not mutants and do not enter the mutation score.
|
|
8
8
|
class CandidateSelection
|
|
9
|
-
attr_reader :skipped_subjects
|
|
9
|
+
attr_reader :skipped_subjects, :selected
|
|
10
10
|
|
|
11
11
|
def initialize(candidates:, map:, limit:, diagnostics:)
|
|
12
12
|
@candidates = candidates
|
|
@@ -14,21 +14,34 @@ module Saboteur
|
|
|
14
14
|
@limit = limit
|
|
15
15
|
@diagnostics = diagnostics
|
|
16
16
|
@skipped_subjects = {}
|
|
17
|
+
@selected = {}
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def for(subject, scope: nil)
|
|
20
|
-
if @map.tests_for(subject).empty?
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
reason = subject.skip_reason || (skip_reason_for(subject) if @map.tests_for(subject).empty?)
|
|
22
|
+
if reason
|
|
23
|
+
@skipped_subjects[subject] = reason
|
|
24
|
+
return @selected[subject] = []
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
list = @candidates.for(subject)
|
|
26
28
|
list = scope.restrict(subject, list) if scope
|
|
27
29
|
omitted = list.size - @limit
|
|
28
30
|
if omitted.positive?
|
|
31
|
+
@diagnostics.record_omission(subject, count: omitted, limit: @limit)
|
|
29
32
|
@diagnostics.warn("#{subject.id}: omitted #{omitted} mutant(s) above max_mutants_per_method (#{@limit})")
|
|
30
33
|
end
|
|
31
|
-
list.first(@limit)
|
|
34
|
+
@selected[subject] = list.first(@limit)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Load-only code is not "no coverage": it ran, just never inside a
|
|
40
|
+
# test, and no forked test can re-run it (see CoverageMap::RAN_AT_LOAD).
|
|
41
|
+
def skip_reason_for(subject)
|
|
42
|
+
return "no coverage" unless @map.ran_at_load?(subject.file, subject.coverage_lines)
|
|
43
|
+
|
|
44
|
+
Selection::CoverageMap::RAN_AT_LOAD
|
|
32
45
|
end
|
|
33
46
|
end
|
|
34
47
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "prism"
|
|
5
|
+
require_relative "../inline_suppression"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
class Engine
|
|
@@ -12,7 +13,10 @@ module Saboteur
|
|
|
12
13
|
def self.build_sources(config)
|
|
13
14
|
root = File.realpath(config.root)
|
|
14
15
|
config["sources"].map do |entry|
|
|
15
|
-
|
|
16
|
+
if entry == "builtin"
|
|
17
|
+
next MutationSource::Builtin.new(arid: config["arid"], operators: config["operators"],
|
|
18
|
+
exclude_operators: config["exclude_operators"])
|
|
19
|
+
end
|
|
16
20
|
|
|
17
21
|
file, const = entry.split(":", 2)
|
|
18
22
|
raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName" unless const
|
|
@@ -22,37 +26,88 @@ module Saboteur
|
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
|
|
25
|
-
def initialize(sources, diagnostics)
|
|
29
|
+
def initialize(sources, diagnostics, hazard_guard: nil, bytecode: BytecodeEquivalence.new)
|
|
26
30
|
@sources = sources
|
|
27
31
|
@diagnostics = diagnostics
|
|
32
|
+
@hazard_guard = hazard_guard
|
|
33
|
+
@bytecode = bytecode
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def for(subject)
|
|
31
|
-
|
|
37
|
+
unless parses?(subject.source)
|
|
38
|
+
@diagnostics.record_generation(subject, source: "subject", reason: "invalid_subject_syntax")
|
|
39
|
+
return []
|
|
40
|
+
end
|
|
32
41
|
|
|
33
42
|
seen = Set[subject.source]
|
|
34
|
-
list = @sources.flat_map { |source| source
|
|
43
|
+
list = @sources.flat_map { |source| proposals(source, subject) }.filter_map do |mutation|
|
|
35
44
|
candidate(subject, mutation, seen)
|
|
36
45
|
end
|
|
46
|
+
list = suppress_inline(list, subject)
|
|
47
|
+
list = suppress_hazards(list, subject)
|
|
37
48
|
list.sort_by.with_index { |(mutation, _), index| [mutation.coarse ? 0 : 1, index] }
|
|
38
49
|
end
|
|
39
50
|
|
|
40
51
|
private
|
|
41
52
|
|
|
53
|
+
def suppress_inline(list, subject)
|
|
54
|
+
directives = InlineSuppression.new(subject.file, subject.file_source)
|
|
55
|
+
list.reject do |mutation, _text|
|
|
56
|
+
directive = directives.reason(subject, mutation)
|
|
57
|
+
@diagnostics.record_inline_suppression(subject, mutation, directive) if directive
|
|
58
|
+
directive
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The hazard guard (issue #102) refuses mutants that would leave a
|
|
63
|
+
# destructive call running with altered inputs; each refusal is counted.
|
|
64
|
+
def suppress_hazards(list, subject)
|
|
65
|
+
return list unless @hazard_guard
|
|
66
|
+
|
|
67
|
+
kept, suppressed = @hazard_guard.partition(list, subject)
|
|
68
|
+
suppressed.each { |(mutation, _)| @diagnostics.record_hazard_suppression(subject, mutation) }
|
|
69
|
+
kept
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def proposals(source, subject)
|
|
73
|
+
proposals = source.mutations_for(subject)
|
|
74
|
+
if proposals.empty?
|
|
75
|
+
@diagnostics.record_generation(subject, source: source_name(source), reason: "empty_generation")
|
|
76
|
+
end
|
|
77
|
+
proposals
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def source_name(source)
|
|
81
|
+
name = source.name if source.respond_to?(:name)
|
|
82
|
+
name || "anonymous"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def record_drop(subject, mutation, reason)
|
|
86
|
+
@diagnostics.record_generation(subject, source: mutation.source, operator: mutation.operator, reason: reason)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
42
90
|
def candidate(subject, mutation, seen)
|
|
43
91
|
text = subject.apply(mutation.edits)
|
|
44
|
-
return if
|
|
92
|
+
return record_drop(subject, mutation, "unchanged") if text == subject.source
|
|
93
|
+
return record_drop(subject, mutation, "duplicate") if seen.include?(text)
|
|
45
94
|
|
|
46
95
|
seen << text
|
|
47
96
|
unless parses?(text)
|
|
48
97
|
@diagnostics.count_invalid_mutation
|
|
49
|
-
return
|
|
98
|
+
return record_drop(subject, mutation, "invalid_syntax")
|
|
50
99
|
end
|
|
51
100
|
|
|
101
|
+
return record_drop(subject, mutation, "equivalent_bytecode") if equivalent?(subject, text)
|
|
102
|
+
|
|
52
103
|
[mutation, text]
|
|
53
104
|
rescue ArgumentError => e
|
|
54
105
|
@diagnostics.warn("#{subject.id}: dropped #{mutation.source} mutation: #{e.message}")
|
|
55
|
-
|
|
106
|
+
record_drop(subject, mutation, "invalid_edit")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def equivalent?(subject, text)
|
|
110
|
+
@bytecode.equivalent?(subject.source, text, frozen_string_literal: subject.frozen_literals)
|
|
56
111
|
end
|
|
57
112
|
|
|
58
113
|
def parses?(text)
|
|
@@ -15,12 +15,13 @@ module Saboteur
|
|
|
15
15
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
16
16
|
LOG = "coverage-build.log"
|
|
17
17
|
|
|
18
|
-
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
|
|
18
|
+
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, load_lines: {})
|
|
19
19
|
@config = config
|
|
20
20
|
@integration = integration
|
|
21
21
|
@hooks = hooks
|
|
22
22
|
@supervisor = supervisor
|
|
23
23
|
@diagnostics = diagnostics
|
|
24
|
+
@load_lines = load_lines
|
|
24
25
|
@root = File.realpath(config.root)
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -31,6 +32,8 @@ module Saboteur
|
|
|
31
32
|
|
|
32
33
|
map = refuse_empty_map(build(files), files)
|
|
33
34
|
map.save(path)
|
|
35
|
+
File.write(File.join(@config.path("cache_dir"), "preview.json"),
|
|
36
|
+
JSON.generate(path: path, fingerprint: RunFingerprint.new(config: @config).snapshot.digest))
|
|
34
37
|
map
|
|
35
38
|
end
|
|
36
39
|
|
|
@@ -57,42 +60,59 @@ module Saboteur
|
|
|
57
60
|
# The suite child forked next inherits this heap; pack it first, to
|
|
58
61
|
# reduce later copying of shared pages.
|
|
59
62
|
Execution::ForkReadiness.prepare_heap
|
|
60
|
-
heartbeat = @diagnostics.progress_heartbeat("building coverage map")
|
|
63
|
+
heartbeat = @diagnostics.progress_heartbeat("building coverage map").watch(log_path)
|
|
61
64
|
result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
|
|
62
65
|
record(writer, files)
|
|
63
66
|
end
|
|
67
|
+
map_from(result)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def map_from(result)
|
|
64
71
|
raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
|
|
65
72
|
raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
|
|
66
73
|
|
|
67
74
|
data = JSON.parse(result.payload)
|
|
68
75
|
raise Error, "coverage map build failed: #{data['error']}" if data["error"]
|
|
69
76
|
|
|
70
|
-
Selection::CoverageMap.from_json(result.payload)
|
|
77
|
+
with_parent_load_lines(Selection::CoverageMap.from_json(result.payload))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The parent's load lines merge into the freshly built map: the forked
|
|
81
|
+
# build child cannot inherit the parent's Coverage counters.
|
|
82
|
+
def with_parent_load_lines(map)
|
|
83
|
+
@load_lines.empty? ? map : map.with_load_lines(@load_lines)
|
|
71
84
|
end
|
|
72
85
|
|
|
73
|
-
# A whole-suite build that records no line
|
|
74
|
-
# the run (exit 3): usually
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
86
|
+
# A whole-suite build that records nothing - no line hit and no method
|
|
87
|
+
# call - in any included file fails the run (exit 3): usually no test
|
|
88
|
+
# in the suite exercises these files, or the setup hid the app from
|
|
89
|
+
# Coverage (Bootsnap's compile cache is the default Rails case). An
|
|
90
|
+
# all-endless-def file records method calls only and passes. Either way
|
|
91
|
+
# a run on a truly empty map would test nothing, and exit 0 would lie.
|
|
92
|
+
# With explicit tests (--test) an empty map is a legitimate outcome
|
|
79
93
|
# instead: the user narrowed the suite to specs that may not touch
|
|
80
94
|
# these sources, so the map passes with a warning. Applies to a cached
|
|
81
95
|
# map too, so a map cached before this check cannot keep a run green,
|
|
82
96
|
# and the guard runs before saving, so an empty map is never cached.
|
|
83
97
|
def refuse_empty_map(map, files)
|
|
84
|
-
return map if files.empty? || map.
|
|
98
|
+
return map if files.empty? || !map.empty?
|
|
85
99
|
|
|
86
100
|
if @config["tests"].empty?
|
|
87
|
-
raise Error, "Coverage recorded
|
|
88
|
-
"build.
|
|
89
|
-
"Run `saboteur doctor`."
|
|
101
|
+
raise Error, "Coverage recorded nothing in #{files.size} included files during a whole-suite " \
|
|
102
|
+
"build. No test in the suite may exercise these files, or the setup hid the app " \
|
|
103
|
+
"from Coverage#{bootsnap_note}. Run `saboteur doctor`."
|
|
90
104
|
end
|
|
91
|
-
@diagnostics.warn("Coverage recorded
|
|
105
|
+
@diagnostics.warn("Coverage recorded nothing in #{files.size} included files; " \
|
|
92
106
|
"the selected specs may not exercise them.")
|
|
93
107
|
map
|
|
94
108
|
end
|
|
95
109
|
|
|
110
|
+
# Bootsnap is named only when it is loaded: blaming a library the app
|
|
111
|
+
# does not use sends the reader to the wrong place.
|
|
112
|
+
def bootsnap_note
|
|
113
|
+
" (Bootsnap's compile cache is the default Rails case)" if defined?(::Bootsnap)
|
|
114
|
+
end
|
|
115
|
+
|
|
96
116
|
def died(status)
|
|
97
117
|
how = if status&.signaled? then "was killed by signal #{Signal.signame(status.termsig)}"
|
|
98
118
|
else "exited with status #{status&.exitstatus}"
|