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
|
@@ -17,28 +17,57 @@ module Saboteur
|
|
|
17
17
|
# to every test in the file of the next test that starts. Each test's
|
|
18
18
|
# duration is recorded so selected tests can run fastest first.
|
|
19
19
|
class CoverageMap
|
|
20
|
-
FORMAT =
|
|
20
|
+
FORMAT = 11
|
|
21
21
|
|
|
22
22
|
RAN_AT_LOAD = "ran at load: the code ran while the app loaded; a forked test cannot observe a change here"
|
|
23
23
|
|
|
24
|
-
attr_reader :lines, :tests, :build_seconds, :durations, :methods, :load_lines, :
|
|
24
|
+
attr_reader :lines, :tests, :build_seconds, :loaded_files, :durations, :methods, :load_lines, :load_methods,
|
|
25
|
+
:branches
|
|
25
26
|
|
|
26
27
|
# lines: { "/abs/file.rb" => { 12 => ["id1", "id2"] } }
|
|
27
28
|
# methods: { "/abs/file.rb" => { 12 => ["id1", "id2"] } } keyed by the
|
|
28
29
|
# def's first line; line coverage never counts an endless def's body,
|
|
29
30
|
# so methods carry those subjects (issue #107).
|
|
30
31
|
# durations: { "id1" => seconds }
|
|
31
|
-
def initialize(lines:, tests:, build_seconds: nil, durations: {}, methods: {}, load_lines: {},
|
|
32
|
+
def initialize(lines:, tests:, build_seconds: nil, durations: {}, methods: {}, load_lines: {}, load_methods: {},
|
|
33
|
+
branches: {}, loaded_files: nil)
|
|
32
34
|
@lines = lines
|
|
33
35
|
@tests = tests
|
|
34
36
|
@build_seconds = build_seconds
|
|
35
37
|
@durations = durations
|
|
36
38
|
@methods = methods
|
|
37
39
|
@load_lines = load_lines
|
|
40
|
+
@load_methods = load_methods
|
|
38
41
|
@branches = branches
|
|
42
|
+
@loaded_files = loaded_files || []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Source files the given tests touched, sorted and deduped: the
|
|
46
|
+
# dependency set a cached verdict must watch (#197). The inversion is
|
|
47
|
+
# built once per map and memoized.
|
|
48
|
+
def files_for(test_ids)
|
|
49
|
+
by_test = @files_by_test ||= invert_coverage
|
|
50
|
+
test_ids.flat_map { |id| by_test.fetch(id, []) }.uniq.sort
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# { test id => [source files it touched] }, built once per map. Line
|
|
54
|
+
# and method coverage both count: an endless def's file has only
|
|
55
|
+
# method evidence (#107), and a change there must still invalidate (#344).
|
|
56
|
+
def invert_coverage
|
|
57
|
+
[lines, methods].each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |hits, acc|
|
|
58
|
+
hits.each { |file, line_tests| line_tests.each_value { |ids| ids.each { |id| acc[id] << file } } }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Files that ran while the app loaded: their state precedes every
|
|
63
|
+
# test, so every cached verdict depends on them (#344).
|
|
64
|
+
def boot_files
|
|
65
|
+
load_lines.keys.sort
|
|
39
66
|
end
|
|
40
67
|
|
|
41
68
|
def tests_for(subject)
|
|
69
|
+
return tests_for_constant(subject) if subject.is_a?(ConstantSubject)
|
|
70
|
+
|
|
42
71
|
tests = tests_for_lines(subject.file, subject.coverage_lines)
|
|
43
72
|
return tests unless tests.empty?
|
|
44
73
|
|
|
@@ -50,10 +79,33 @@ module Saboteur
|
|
|
50
79
|
sort_test_ids(ids)
|
|
51
80
|
end
|
|
52
81
|
|
|
82
|
+
# A constant is observable wherever it is read: the tests that ran
|
|
83
|
+
# any read of it, across every file the consumer scan found (#306).
|
|
84
|
+
# A read inside an endless def has no line coverage, so its def's
|
|
85
|
+
# per-test method-call counter stands in (#319).
|
|
86
|
+
def tests_for_constant(subject)
|
|
87
|
+
ids = subject.consumer_lines.flat_map { |file, lines| tests_for_lines(file, lines) }
|
|
88
|
+
ids += subject.consumer_defs.flat_map do |file, def_lines|
|
|
89
|
+
by_method = methods[CoverageMap.canonical(file)]
|
|
90
|
+
by_method ? def_lines.flat_map { |line| by_method.fetch(line, []) } : []
|
|
91
|
+
end
|
|
92
|
+
sort_test_ids(ids.uniq)
|
|
93
|
+
end
|
|
94
|
+
|
|
53
95
|
# Attach the parent's boot counters without losing the child's method coverage.
|
|
54
|
-
def with_load_lines(load_lines)
|
|
96
|
+
def with_load_lines(load_lines, load_methods = {})
|
|
55
97
|
self.class.new(lines: lines, tests: tests, build_seconds: build_seconds, durations: durations,
|
|
56
|
-
methods: methods, load_lines: load_lines, branches: branches
|
|
98
|
+
methods: methods, load_lines: load_lines, load_methods: load_methods, branches: branches,
|
|
99
|
+
loaded_files: loaded_files)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A def the boot called (a def-time macro's class-body caller, say)
|
|
103
|
+
# and no test or hook called again: re-evaluating it post-boot
|
|
104
|
+
# changes nothing a selected test can observe, because closure
|
|
105
|
+
# consumers captured the pristine definition (#409).
|
|
106
|
+
def called_only_at_load?(file, line)
|
|
107
|
+
file = CoverageMap.canonical(file)
|
|
108
|
+
load_methods.fetch(file, []).include?(line) && methods.fetch(file, {}).fetch(line, []).empty?
|
|
57
109
|
end
|
|
58
110
|
|
|
59
111
|
# A requested line ran at load but no test later hit it.
|
|
@@ -69,6 +121,11 @@ module Saboteur
|
|
|
69
121
|
# "no coverage" skip, but a subject whose lines never record at all
|
|
70
122
|
# (an endless def) falls back to its method coverage (issue #107).
|
|
71
123
|
def tests_for_edit(subject, line_numbers)
|
|
124
|
+
# An edit to a constant literal is observable at every read of the
|
|
125
|
+
# constant, not on the literal's own (load-time) lines: narrowing
|
|
126
|
+
# by edit lines is meaningless (#306).
|
|
127
|
+
return tests_for(subject) if subject.is_a?(ConstantSubject)
|
|
128
|
+
|
|
72
129
|
tests = tests_for_lines(subject.file, line_numbers)
|
|
73
130
|
return tests unless tests.empty?
|
|
74
131
|
return [] unless tests_for_lines(subject.file, subject.coverage_lines).empty?
|
|
@@ -100,6 +157,17 @@ module Saboteur
|
|
|
100
157
|
[lines, methods, load_lines].all? { |hits| hits.none? { |_file, values| values.any? } }
|
|
101
158
|
end
|
|
102
159
|
|
|
160
|
+
# True when none of these files recorded a line hit, a method call, or
|
|
161
|
+
# a load-time hit. The recorder's map also holds the suite's spec
|
|
162
|
+
# files, so whole-map emptiness cannot tell a build that recorded test
|
|
163
|
+
# code only while the included files stayed dark.
|
|
164
|
+
def empty_for?(files)
|
|
165
|
+
wanted = files.map { |file| CoverageMap.canonical(file) }
|
|
166
|
+
[lines, methods, load_lines].all? do |hits|
|
|
167
|
+
wanted.none? { |file| hits[file]&.any? }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
103
171
|
# Tests that executed any of the lines, fastest first (suite order breaks
|
|
104
172
|
# ties), no duplicates. With fail-fast, a mutant is killed by its
|
|
105
173
|
# cheapest killing test.
|
|
@@ -124,15 +192,19 @@ module Saboteur
|
|
|
124
192
|
hits.transform_values { |by_line| by_line.to_h { |line, ids| [line.to_s, ids.map { |id| index.fetch(id) }] } }
|
|
125
193
|
end
|
|
126
194
|
JSON.generate(format: FORMAT, tests: tests, build_seconds: build_seconds, lines: encode.call(lines),
|
|
127
|
-
methods: encode.call(methods), load_lines: load_lines,
|
|
195
|
+
methods: encode.call(methods), load_lines: load_lines, load_methods: load_methods,
|
|
128
196
|
branches: encode_branches(index),
|
|
129
|
-
durations: tests.map { |id| durations[id]&.round(6) })
|
|
197
|
+
durations: tests.map { |id| durations[id]&.round(6) }, loaded_files: loaded_files)
|
|
130
198
|
end
|
|
131
199
|
|
|
132
200
|
def self.from_json(json)
|
|
133
201
|
data = JSON.parse(json)
|
|
134
202
|
raise Error, "coverage map format #{data['format']} is not #{FORMAT}" unless data["format"] == FORMAT
|
|
135
203
|
|
|
204
|
+
from_data(data)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.from_data(data)
|
|
136
208
|
tests = data["tests"]
|
|
137
209
|
decode = lambda do |hits|
|
|
138
210
|
hits.transform_values do |by_line|
|
|
@@ -140,10 +212,14 @@ module Saboteur
|
|
|
140
212
|
end
|
|
141
213
|
end
|
|
142
214
|
durations = tests.zip(data["durations"]).to_h.compact
|
|
143
|
-
load_lines = (data["load_lines"] || {}).transform_values { |list| list.map(&:to_i) }
|
|
144
215
|
new(lines: decode.call(data["lines"]), tests: tests, build_seconds: data["build_seconds"],
|
|
145
|
-
durations: durations, methods: decode.call(data["methods"] || {}),
|
|
146
|
-
|
|
216
|
+
durations: durations, methods: decode.call(data["methods"] || {}),
|
|
217
|
+
load_lines: int_lists(data, "load_lines"), load_methods: int_lists(data, "load_methods"),
|
|
218
|
+
branches: decode_branches(data.fetch("branches", {}), tests), loaded_files: data["loaded_files"])
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def self.int_lists(data, key)
|
|
222
|
+
(data[key] || {}).transform_values { |list| list.map(&:to_i) }
|
|
147
223
|
end
|
|
148
224
|
|
|
149
225
|
def self.decode_branches(branches, tests)
|
|
@@ -168,6 +244,25 @@ module Saboteur
|
|
|
168
244
|
File.expand_path(path)
|
|
169
245
|
end
|
|
170
246
|
|
|
247
|
+
# Every spelling the root can have in $LOADED_FEATURES: a symlink on
|
|
248
|
+
# the root path (macOS /var -> /private/var) gives the same directory
|
|
249
|
+
# both the resolved and the spelled form, and canonical() resolves a
|
|
250
|
+
# loaded feature only while its file exists. Match membership against
|
|
251
|
+
# both, each with a trailing slash so prefix siblings stay out.
|
|
252
|
+
def self.root_prefixes(root)
|
|
253
|
+
[File.realpath(root), File.expand_path(root)].uniq.map { |path| "#{path}/" }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The dependency universe a cached verdict watches when coverage
|
|
257
|
+
# cannot speak: every project file the booted process loaded, plus
|
|
258
|
+
# the run's own files (#344).
|
|
259
|
+
def self.loaded_project_files(files, prefixes:)
|
|
260
|
+
$LOADED_FEATURES.filter_map do |file|
|
|
261
|
+
path = Text.utf8_strict(canonical(file))
|
|
262
|
+
path if path.end_with?(".rb") && prefixes.any? { |prefix| path.start_with?(prefix) }
|
|
263
|
+
end | files
|
|
264
|
+
end
|
|
265
|
+
|
|
171
266
|
def self.load(path)
|
|
172
267
|
from_json(Text.utf8_strict(File.read(path))) if File.file?(path)
|
|
173
268
|
rescue JSON::ParserError, Error
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Selection
|
|
6
|
+
# Dependency trees are never changed source (#504): a vendored bundle
|
|
7
|
+
# or node_modules, tracked or untracked, git-ignored or not, is
|
|
8
|
+
# rejected at enumeration, so --since never lists, reads or warns on
|
|
9
|
+
# it. The project-local bundler path (.bundle/config's BUNDLE_PATH)
|
|
10
|
+
# joins the shield when set - the standard CI layout vendor/bundle is
|
|
11
|
+
# already under vendor/. The config file itself is project
|
|
12
|
+
# configuration, not vendored source, and stays visible.
|
|
13
|
+
module DependencyShield
|
|
14
|
+
TREES = %w[vendor/ node_modules/].freeze
|
|
15
|
+
BUNDLER_CONFIG = ".bundle/config"
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def reject_trees(paths, root)
|
|
20
|
+
bundler = bundler_path(root)
|
|
21
|
+
paths.reject do |path|
|
|
22
|
+
next false if path == BUNDLER_CONFIG
|
|
23
|
+
|
|
24
|
+
TREES.any? { |tree| path.start_with?(tree) } || (bundler && path.start_with?(bundler))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# .bundle/config's BUNDLE_PATH as a relative directory prefix, or
|
|
29
|
+
# nil when no project-local config sets one (an absolute path is the
|
|
30
|
+
# system bundle, outside the tree, so nothing to shield).
|
|
31
|
+
def bundler_path(root)
|
|
32
|
+
config = File.join(root, ".bundle", "config")
|
|
33
|
+
return unless File.file?(config)
|
|
34
|
+
|
|
35
|
+
line = File.foreach(config).find { |entry| entry.start_with?("BUNDLE_PATH:") }
|
|
36
|
+
return unless line
|
|
37
|
+
|
|
38
|
+
value = scalar(line.split(":", 2).last.strip)
|
|
39
|
+
return if value.empty?
|
|
40
|
+
|
|
41
|
+
# BUNDLE_PATH: . (or ./) is the project root itself - the empty
|
|
42
|
+
# prefix would match every path and shield the whole tree (#580) -
|
|
43
|
+
# and anything outside the root is the system bundle, not project
|
|
44
|
+
# source. The prefix is the normalized path, not the raw scalar:
|
|
45
|
+
# ./deps/../deps shields deps/.
|
|
46
|
+
base = File.expand_path(root)
|
|
47
|
+
expanded = File.expand_path(value, base)
|
|
48
|
+
return if expanded == base || !expanded.start_with?("#{base}/")
|
|
49
|
+
|
|
50
|
+
"#{expanded.delete_prefix("#{base}/")}/"
|
|
51
|
+
# ArgumentError too: a NUL byte in the value makes File.expand_path
|
|
52
|
+
# raise it, and a config that cannot be read means no shield.
|
|
53
|
+
rescue SystemCallError, ArgumentError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The scalar after "BUNDLE_PATH:": bundler writes a bare word, but
|
|
58
|
+
# YAML also allows it quoted - surrounding matching quotes are not
|
|
59
|
+
# part of the path.
|
|
60
|
+
def scalar(raw)
|
|
61
|
+
quote = raw[0]
|
|
62
|
+
value = if raw.length >= 2 && raw[-1] == quote && ["'", '"'].include?(quote)
|
|
63
|
+
raw[1..-2]
|
|
64
|
+
else
|
|
65
|
+
raw
|
|
66
|
+
end
|
|
67
|
+
strip_comment(value)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Bundler::YAMLSerializer#strip_comment, which parses .bundle/config
|
|
71
|
+
# for Bundler itself: a # after the first character starts a comment
|
|
72
|
+
# even inside quotes, while a leading # is data.
|
|
73
|
+
def strip_comment(value)
|
|
74
|
+
return value unless value.include?("#") && !value.start_with?("#")
|
|
75
|
+
|
|
76
|
+
value.split("#", 2).first.strip
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Selection
|
|
6
|
+
# The --test-command mode has no coverage map (#140): the command runs
|
|
7
|
+
# the whole suite per mutant, so every subject reads as covered by the
|
|
8
|
+
# one pseudo-test and no recorded timing exists, leaving the per-mutant
|
|
9
|
+
# timeout to the config's baseline_timeout.
|
|
10
|
+
class ExternalTestMap
|
|
11
|
+
def tests = [Execution::ExternalRunner::PSEUDO_TEST]
|
|
12
|
+
|
|
13
|
+
def tests_for(_subject) = tests
|
|
14
|
+
|
|
15
|
+
def tests_for_edit(_subject, _lines) = tests
|
|
16
|
+
|
|
17
|
+
def tests_for_branches(_subject, _edits) = tests
|
|
18
|
+
|
|
19
|
+
def ran_at_load?(_file, _lines) = false
|
|
20
|
+
|
|
21
|
+
# No boot happens in this mode, so nothing can have run only at load.
|
|
22
|
+
def called_only_at_load?(_file, _line) = false
|
|
23
|
+
|
|
24
|
+
def recorded_runtime(_tests) = nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
require "open3"
|
|
5
5
|
require_relative "../text"
|
|
6
|
+
require_relative "dependency_shield"
|
|
7
|
+
require_relative "layout_only_lines"
|
|
6
8
|
require_relative "untracked_changes"
|
|
7
9
|
|
|
8
10
|
module Saboteur
|
|
@@ -11,7 +13,7 @@ module Saboteur
|
|
|
11
13
|
# changes, as { "/abs/path.rb" => Set[line numbers in the current file] }.
|
|
12
14
|
module GitDiff
|
|
13
15
|
NO_FILE = "/dev/null" # rubocop:disable Style/FileNull -- literal in git diff output
|
|
14
|
-
HUNK = /\A@@
|
|
16
|
+
HUNK = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
15
17
|
|
|
16
18
|
module_function
|
|
17
19
|
|
|
@@ -41,7 +43,61 @@ module Saboteur
|
|
|
41
43
|
status.success? ? out.b : nil
|
|
42
44
|
end
|
|
43
45
|
|
|
46
|
+
# Paths under ROOT, relative to it, that differ between the merge base
|
|
47
|
+
# of REF and the working tree: { present: [...], deleted: [...] }.
|
|
48
|
+
# present holds added, modified and untracked files; deleted holds
|
|
49
|
+
# files gone from the working tree. A rename counts as a deletion of
|
|
50
|
+
# the old path and an addition of the new one.
|
|
51
|
+
def changed_paths(ref, root:)
|
|
52
|
+
verify_ref!(ref, root: root)
|
|
53
|
+
base = merge_base(ref, root: root)
|
|
54
|
+
diff = %w[diff -z --name-only --relative --no-renames]
|
|
55
|
+
{
|
|
56
|
+
# An untracked file is never in the diff, so the two lists are disjoint.
|
|
57
|
+
present: DependencyShield.reject_trees(names(root, *diff, "--diff-filter=d", base, "--") +
|
|
58
|
+
names(root, "ls-files", "-z", "--others",
|
|
59
|
+
"--exclude-standard"), root).sort,
|
|
60
|
+
deleted: DependencyShield.reject_trees(names(root, *diff, "--diff-filter=D", base, "--"),
|
|
61
|
+
root).sort
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def names(root, *args)
|
|
66
|
+
git(root, *args).b.split("\0").map { |name| Text.utf8_identity(name) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The two line maps of a --since diff (#313). changed holds every line
|
|
70
|
+
# the text diff touches: subject admission and the formatting-only
|
|
71
|
+
# skip read it, so a method that was only re-indented is still found
|
|
72
|
+
# and reported as unchanged. behavior drops the layout-only lines:
|
|
73
|
+
# only it narrows a changed method's fine-grained mutants.
|
|
74
|
+
LineMaps = Data.define(:changed, :behavior)
|
|
75
|
+
|
|
44
76
|
def changed_lines(ref, root:, include: nil, diagnostics: nil)
|
|
77
|
+
line_maps(ref, root: root, include: include, diagnostics: diagnostics).changed
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Tracked files whose diff deletes lines without adding any, as
|
|
81
|
+
# canonical absolute paths (realpath'd toplevel joined to each diff
|
|
82
|
+
# path), so callers compare in the same space as
|
|
83
|
+
# CoverageMap.canonical keys whatever the project-root layout: a
|
|
84
|
+
# monorepo root below the git toplevel must not silently disable the
|
|
85
|
+
# exclusion (#484). A pure deletion adds no new code to mutate - the
|
|
86
|
+
# change itself is what the suite covers - so the file yields no
|
|
87
|
+
# subject and no fine-grained mutant, and a spec-less such file must
|
|
88
|
+
# not stop the run. The neighbor lines a pure deletion touches
|
|
89
|
+
# (touched_lines) are context, not change. Untracked files are
|
|
90
|
+
# all-additions and never listed.
|
|
91
|
+
def deletion_only_files(ref, root:) # rubocop:disable Metrics/ModuleLength -- one focused addition to an exactly-full module
|
|
92
|
+
top = File.realpath(Text.utf8_identity(git(root, "rev-parse", "--show-toplevel").b.strip))
|
|
93
|
+
diff = git(root, "-c", "core.quotePath=false", "diff", "--unified=0", "--no-color", "--no-ext-diff",
|
|
94
|
+
merge_base(ref, root: root), "--").b
|
|
95
|
+
added = Hash.new { |hash, file| hash[file] = false }
|
|
96
|
+
each_hunk(diff, top) { |file, (_, _, _, count)| added[file] ||= count.positive? }
|
|
97
|
+
added.reject { |_, yes| yes }.keys
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def line_maps(ref, root:, include: nil, diagnostics: nil)
|
|
45
101
|
verify_ref!(ref, root: root)
|
|
46
102
|
base = merge_base(ref, root: root)
|
|
47
103
|
# Root and paths are filesystem identity: strip as bytes and retag
|
|
@@ -49,14 +105,30 @@ module Saboteur
|
|
|
49
105
|
# would rename a raw-byte path). Parse the diff body as bytes and
|
|
50
106
|
# tag each extracted path UTF-8 at the boundary.
|
|
51
107
|
top = Text.utf8_identity(git(root, "rev-parse", "--show-toplevel").b.strip)
|
|
52
|
-
|
|
53
|
-
|
|
108
|
+
diff = %w[-c core.quotePath=false diff --unified=0 --no-color --no-ext-diff]
|
|
109
|
+
changes = parse(git(root, *diff, base, "--").b, top)
|
|
54
110
|
UntrackedChanges.union(changes, top, include: include, diagnostics: diagnostics)
|
|
55
|
-
changes
|
|
111
|
+
behavior = drop_layout_only_lines(changes, git(root, *diff, "--ignore-space-change", base, "--").b, base,
|
|
112
|
+
top, include: include)
|
|
113
|
+
LineMaps.new(changed: changes, behavior: behavior)
|
|
56
114
|
end
|
|
57
115
|
|
|
58
116
|
def parse(diff, top)
|
|
59
117
|
changes = Hash.new { |hash, file| hash[file] = Set.new }
|
|
118
|
+
each_hunk(diff, top) { |file, (_, _, start, count)| changes[file].merge(touched_lines(start, count)) }
|
|
119
|
+
changes.default_proc = nil
|
|
120
|
+
changes
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The new-side lines a hunk touches. A pure deletion touches the lines
|
|
124
|
+
# on either side of it.
|
|
125
|
+
def touched_lines(start, count)
|
|
126
|
+
count.zero? ? [start, start + 1] : (start...(start + count)).to_a
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Each hunk header of a zero-context diff as the absolute path of the
|
|
130
|
+
# file and [old_start, old_count, new_start, new_count].
|
|
131
|
+
def each_hunk(diff, top)
|
|
60
132
|
file = nil
|
|
61
133
|
diff.each_line do |line|
|
|
62
134
|
if line.start_with?("+++ ")
|
|
@@ -65,15 +137,25 @@ module Saboteur
|
|
|
65
137
|
# HUNK is anchored, so a non-hunk line - including a raw-byte
|
|
66
138
|
# content line - fails the match at its first byte.
|
|
67
139
|
elsif file && (match = HUNK.match(line))
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
# A pure deletion touches the lines on either side of it.
|
|
71
|
-
lines = count.zero? ? [start, start + 1] : (start...(start + count)).to_a
|
|
72
|
-
changes[file].merge(lines)
|
|
140
|
+
old_start, old_count, new_start, new_count = match.captures
|
|
141
|
+
yield file, [old_start.to_i, (old_count || "1").to_i, new_start.to_i, (new_count || "1").to_i]
|
|
73
142
|
end
|
|
74
143
|
end
|
|
75
|
-
|
|
76
|
-
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Only files the run could mutate are lexed; a file new at the base
|
|
147
|
+
# has nothing to compare with and keeps every line.
|
|
148
|
+
def drop_layout_only_lines(changes, spaced_diff, base, top, include:)
|
|
149
|
+
hunks = Hash.new { |hash, file| hash[file] = [] }
|
|
150
|
+
each_hunk(spaced_diff, top) { |file, hunk| hunks[file] << hunk }
|
|
151
|
+
changes.to_h do |file, lines|
|
|
152
|
+
mutable = include.nil? || include.include?(file)
|
|
153
|
+
base_source = mutable && file_at(base, file.delete_prefix("#{top}/"), root: top)
|
|
154
|
+
next [file, lines] unless base_source
|
|
155
|
+
|
|
156
|
+
[file, LayoutOnlyLines.new(hunks[file], base_source: base_source, current_source: File.binread(file))
|
|
157
|
+
.restrict(lines)]
|
|
158
|
+
end
|
|
77
159
|
end
|
|
78
160
|
|
|
79
161
|
ESCAPES = { "n" => "\n", "t" => "\t", '"' => '"', "\\" => "\\", "a" => "\a", "b" => "\b",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Selection
|
|
8
|
+
# Drops changed lines whose only change is layout (#313). Wrapping
|
|
9
|
+
# existing statements in a block re-indents them; a plain diff marks
|
|
10
|
+
# every one as changed, so their mutants run as if the change touched
|
|
11
|
+
# them.
|
|
12
|
+
#
|
|
13
|
+
# A whitespace-insensitive diff (git diff -b) finds the lines that
|
|
14
|
+
# differ by more than spacing and pairs every other line with its line
|
|
15
|
+
# at the base. A changed line outside that diff is dropped only when
|
|
16
|
+
# Prism lexes it to the same tokens as its base line: whitespace inside
|
|
17
|
+
# a string, heredoc or regexp is part of a token, so it still counts as
|
|
18
|
+
# a change. A `__LINE__` on a line that moved names another number, so
|
|
19
|
+
# that line counts too.
|
|
20
|
+
class LayoutOnlyLines
|
|
21
|
+
LINE_KEYWORD = :KEYWORD___LINE__
|
|
22
|
+
|
|
23
|
+
# hunks: [[old_start, old_count, new_start, new_count], ...] of the
|
|
24
|
+
# whitespace-insensitive diff of one file, in order.
|
|
25
|
+
def initialize(hunks, base_source:, current_source:)
|
|
26
|
+
@hunks = hunks
|
|
27
|
+
@base_tokens = tokens_by_line(base_source)
|
|
28
|
+
@current_tokens = tokens_by_line(current_source)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The -b diff's own lines, with its deletion markers, plus every line
|
|
32
|
+
# of CHANGED whose tokens differ from its base line.
|
|
33
|
+
def restrict(changed)
|
|
34
|
+
spaced_lines | changed.reject { |line| same_tokens?(line) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def spaced_lines
|
|
40
|
+
@hunks.each_with_object(Set.new) do |(_, _, start, count), lines|
|
|
41
|
+
lines.merge(GitDiff.touched_lines(start, count))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def same_tokens?(line)
|
|
46
|
+
base_line = base_line_of(line)
|
|
47
|
+
tokens = @current_tokens[line]
|
|
48
|
+
return false if base_line != line && tokens.any? { |type, _| type == LINE_KEYWORD }
|
|
49
|
+
|
|
50
|
+
tokens == @base_tokens[base_line]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A line outside every hunk sits at its base position shifted by the
|
|
54
|
+
# lines the hunks above it added or removed. A line inside a hunk is
|
|
55
|
+
# one of the -b diff's own lines and is kept whatever this answers.
|
|
56
|
+
def base_line_of(line)
|
|
57
|
+
line - @hunks.sum { |_, old_count, start, count| start + count <= line ? count - old_count : 0 }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Every token on each line it spans, as type and text. Every line has
|
|
61
|
+
# one (a blank line its newline, text after __END__ that token), and a
|
|
62
|
+
# missing line reads as [] through the default. A token that
|
|
63
|
+
# spans lines (a heredoc body, a multi-line string) belongs to each.
|
|
64
|
+
# A token's location ends after its last byte, so one that ends in a
|
|
65
|
+
# newline would reach into the next line without this.
|
|
66
|
+
def tokens_by_line(source)
|
|
67
|
+
Prism.lex(source).value.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(token, _), lines|
|
|
68
|
+
next if token.type == :EOF
|
|
69
|
+
|
|
70
|
+
token.location.start_line.upto(last_line(token)) { |line| lines[line] << [token.type, token.value] }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def last_line(token)
|
|
75
|
+
location = token.location
|
|
76
|
+
token.value.end_with?("\n") ? location.end_line - 1 : location.end_line
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -25,7 +25,7 @@ module Saboteur
|
|
|
25
25
|
|
|
26
26
|
def start
|
|
27
27
|
Coverage.resume if Coverage.state == :suspended
|
|
28
|
-
|
|
28
|
+
CoverageReads.result(stop: false, clear: true)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def test_started(id, test_file)
|
|
@@ -44,14 +44,15 @@ module Saboteur
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def result(build_seconds: nil)
|
|
47
|
+
def result(build_seconds: nil, loaded_files: [])
|
|
48
48
|
assign_outside(@outside, @lines)
|
|
49
49
|
assign_outside(@outside_methods, @methods)
|
|
50
50
|
assign_outside(@outside_branches, @branches)
|
|
51
51
|
lines = @lines.transform_values { |by_line| by_line.transform_values(&:uniq) }
|
|
52
52
|
methods = @methods.transform_values { |by_line| by_line.transform_values(&:uniq) }
|
|
53
|
+
branches = @branches.transform_values { |arms| arms.transform_values(&:uniq) }
|
|
53
54
|
CoverageMap.new(lines: lines, tests: @tests, build_seconds: build_seconds, durations: @durations,
|
|
54
|
-
methods: methods, branches:
|
|
55
|
+
methods: methods, branches: branches, loaded_files: loaded_files)
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
private
|
|
@@ -65,8 +66,15 @@ module Saboteur
|
|
|
65
66
|
|
|
66
67
|
def record_branches(file, data)
|
|
67
68
|
data.fetch(:branches, {}).each do |(_kind, _id, *parent_span), arms|
|
|
69
|
+
spans = arms.map { |(_arm_kind, _arm_id, *span), _count| span }
|
|
68
70
|
arms.each do |(_arm_kind, _arm_id, *span), count|
|
|
69
71
|
next if span == parent_span
|
|
72
|
+
# Ruby spans a `when X` arm with a bare `nil` body from the
|
|
73
|
+
# `when` to the case's end, containing the sibling arms.
|
|
74
|
+
# Sibling arms are disjoint by definition, so a containing span
|
|
75
|
+
# is bogus; recording it would let it intersect and narrow
|
|
76
|
+
# selection for edits inside the siblings it swallows.
|
|
77
|
+
next if spans.any? { |other| span != other && contains?(span, other) }
|
|
70
78
|
|
|
71
79
|
@branches[file][span] # Keep zero-hit arms as evidence, not as covered code.
|
|
72
80
|
yield span if count.positive?
|
|
@@ -74,6 +82,16 @@ module Saboteur
|
|
|
74
82
|
end
|
|
75
83
|
end
|
|
76
84
|
|
|
85
|
+
# [line, column] pairs compare lexically, like CoverageMap#position.
|
|
86
|
+
# Both endpoints come from one slice per side: a separate slice per
|
|
87
|
+
# comparand would make its size irrelevant on the right-hand side of
|
|
88
|
+
# `<=>` (a prefix decides first), hiding an equivalent mutant.
|
|
89
|
+
def contains?(outer, inner)
|
|
90
|
+
outer_start, inner_start = [outer, inner].map { |span| span.first(2) }
|
|
91
|
+
inner_end, outer_end = [inner, outer].map { |span| span.last(2) }
|
|
92
|
+
(outer_start <=> inner_start) <= 0 && (inner_end <=> outer_end) <= 0
|
|
93
|
+
end
|
|
94
|
+
|
|
77
95
|
def hit_table
|
|
78
96
|
Hash.new { |hash, file| hash[file] = Hash.new { |by_line, line| by_line[line] = [] } }
|
|
79
97
|
end
|
|
@@ -89,7 +107,7 @@ module Saboteur
|
|
|
89
107
|
# Coverage is suspended while the delta is processed, so the recorder's
|
|
90
108
|
# own work (and anything it calls) is never attributed to a test.
|
|
91
109
|
def each_hit
|
|
92
|
-
snapshot =
|
|
110
|
+
snapshot = CoverageReads.result(stop: false, clear: true)
|
|
93
111
|
suspended = Coverage.running?
|
|
94
112
|
Coverage.suspend if suspended
|
|
95
113
|
snapshot.each do |path, data|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Selection
|
|
6
|
+
# Positional source-scope arguments from the command line.
|
|
7
|
+
module SourceScope
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# "One --test, several paths" lands the extra paths in the source
|
|
11
|
+
# scope; specs are never subjects, so the run would mutate nothing
|
|
12
|
+
# and still exit 0. Refuse them as a usage error before any boot.
|
|
13
|
+
def verify_no_specs!(paths)
|
|
14
|
+
spec_files = paths.select { |path| path.end_with?("_spec.rb") }
|
|
15
|
+
return if spec_files.empty?
|
|
16
|
+
|
|
17
|
+
raise ConfigError,
|
|
18
|
+
"spec files are not mutated - pass them with --test, one path per use: #{spec_files.join(', ')}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|