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
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "../text"
|
|
5
|
+
require_relative "../constant_subject"
|
|
6
|
+
require_relative "../constant_subject_finder"
|
|
7
|
+
require_relative "../constant_reads"
|
|
5
8
|
|
|
6
9
|
module Saboteur
|
|
7
10
|
class Engine
|
|
@@ -10,20 +13,30 @@ module Saboteur
|
|
|
10
13
|
class SubjectSelection
|
|
11
14
|
# Lines changed since the --since ref, by file; nil without --since.
|
|
12
15
|
attr_reader :changed_lines
|
|
16
|
+
# The changed lines minus layout-only ones (#313): the lines that
|
|
17
|
+
# narrow a changed method's fine-grained mutants.
|
|
18
|
+
attr_reader :behavior_lines
|
|
13
19
|
|
|
14
|
-
def initialize(config, diagnostics)
|
|
20
|
+
def initialize(config, diagnostics, test_files: nil, custom_map: nil)
|
|
15
21
|
@config = config
|
|
16
22
|
@diagnostics = diagnostics
|
|
17
23
|
@root = File.realpath(config.root)
|
|
24
|
+
@test_files = test_files
|
|
25
|
+
@custom_map = custom_map || CustomMap.load(config.path("custom_map"), root: config.root)
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
def select(files)
|
|
21
29
|
@files = files
|
|
22
30
|
subjects = find(files)
|
|
31
|
+
if @config["since"]
|
|
32
|
+
@changed_lines, @behavior_lines = changed_line_maps
|
|
33
|
+
subjects += constant_subjects
|
|
34
|
+
end
|
|
23
35
|
@discovered = subjects
|
|
24
36
|
subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
|
|
25
37
|
subjects = by_change(subjects, @config["since"]) if @config["since"]
|
|
26
|
-
subjects
|
|
38
|
+
subjects = drop_intentionally_untested(subjects)
|
|
39
|
+
attach_consumers(subjects)
|
|
27
40
|
end
|
|
28
41
|
|
|
29
42
|
# "Klass#meth", "Klass.meth", or a namespace prefix ("Klass", "A::B").
|
|
@@ -34,15 +47,24 @@ module Saboteur
|
|
|
34
47
|
# The files (absolute paths) holding a subject matching any pattern,
|
|
35
48
|
# returned relative to root. --subject inference guesses specs for
|
|
36
49
|
# exactly these, so one unrelated spec-less file cannot block a
|
|
37
|
-
# one-method run.
|
|
38
|
-
|
|
50
|
+
# one-method run. With --since, constant literal subjects count too:
|
|
51
|
+
# a file holding only the named constant otherwise falls out of
|
|
52
|
+
# inference and runs the whole suite (#319).
|
|
53
|
+
def self.files_matching(root, files, patterns, config = { "since" => nil })
|
|
54
|
+
include_constants = !config["since"].nil?
|
|
39
55
|
finder = SubjectFinder.new
|
|
56
|
+
constant_finder = ConstantSubjectFinder.new if include_constants
|
|
40
57
|
prefix = "#{root}/"
|
|
41
58
|
files.filter_map do |file|
|
|
42
59
|
parsed = finder.call(file)
|
|
43
60
|
matching = parsed.subjects.any? do |subject|
|
|
44
61
|
patterns.any? { |pattern| match?(subject.id, pattern) }
|
|
45
62
|
end
|
|
63
|
+
if !matching && constant_finder
|
|
64
|
+
matching = constant_finder.call(file).subjects.any? do |subject|
|
|
65
|
+
patterns.any? { |pattern| match?(subject.id, pattern) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
46
68
|
file.delete_prefix(prefix) if matching
|
|
47
69
|
end.sort
|
|
48
70
|
end
|
|
@@ -52,36 +74,188 @@ module Saboteur
|
|
|
52
74
|
def find(files)
|
|
53
75
|
finder = SubjectFinder.new
|
|
54
76
|
files.flat_map do |file|
|
|
55
|
-
|
|
77
|
+
source = File.read(file)
|
|
78
|
+
InlineSuppression.new(file, source)
|
|
56
79
|
parsed = finder.call(file)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
unless parsed.errors.empty?
|
|
81
|
+
if source.include?("<%")
|
|
82
|
+
@diagnostics.record_erb_template_skip(file, root: @root)
|
|
83
|
+
else
|
|
84
|
+
@diagnostics.record_discovery_failure(file, reason: "invalid_file_syntax")
|
|
85
|
+
parsed.errors.first(1).each do |message|
|
|
86
|
+
@diagnostics.warn("#{file.delete_prefix("#{@root}/")} does not parse: #{message}")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
60
89
|
end
|
|
61
90
|
parsed.subjects
|
|
62
91
|
end
|
|
63
92
|
end
|
|
64
93
|
|
|
65
94
|
def by_pattern(subjects, patterns)
|
|
66
|
-
patterns.
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
missed = patterns.reject do |pattern|
|
|
96
|
+
subjects.any? { |subject| self.class.match?(subject.id, pattern) }
|
|
97
|
+
end
|
|
98
|
+
missed.each do |pattern|
|
|
69
99
|
@diagnostics.warn("--subject #{pattern} matches no subject (patterns are names like " \
|
|
70
100
|
"Klass, Klass#meth or Klass.meth, not globs)")
|
|
71
101
|
end
|
|
102
|
+
# A total miss is a typo: refuse instead of a green zero-subject
|
|
103
|
+
# summary (#379). A partial miss runs, but the matched count is
|
|
104
|
+
# on the record.
|
|
105
|
+
if missed.size == patterns.size
|
|
106
|
+
raise ConfigError, "--subject #{patterns.join(', ')} matches no subject (patterns are names like " \
|
|
107
|
+
"Klass, Klass#meth or Klass.meth, not globs)"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if missed.any?
|
|
111
|
+
@diagnostics.warn("#{patterns.size - missed.size} of #{patterns.size} --subject patterns matched")
|
|
112
|
+
end
|
|
72
113
|
subjects.select { |subject| patterns.any? { |pattern| self.class.match?(subject.id, pattern) } }
|
|
73
114
|
end
|
|
74
115
|
|
|
75
116
|
def by_change(subjects, ref)
|
|
76
|
-
@changed_lines = Selection::GitDiff.changed_lines(ref, root: @root, include: @files.to_set,
|
|
77
|
-
diagnostics: @diagnostics)
|
|
78
|
-
.transform_keys { |file| Selection::CoverageMap.canonical(file) }
|
|
79
117
|
changed = changed_subjects(subjects)
|
|
80
118
|
warn_changed_files_without_subjects
|
|
81
119
|
warn_excluded_changed_files
|
|
82
120
|
drop_unchanged_syntax(changed, ref)
|
|
83
121
|
end
|
|
84
122
|
|
|
123
|
+
# Lines changed since the --since ref, and their behavior-only
|
|
124
|
+
# subset, canonicalized by file.
|
|
125
|
+
def changed_line_maps
|
|
126
|
+
maps = Selection::GitDiff.line_maps(@config["since"], root: @root, include: @files.to_set,
|
|
127
|
+
diagnostics: @diagnostics)
|
|
128
|
+
# A deletion-only file yields no subject and no fine-grained mutant
|
|
129
|
+
# (#484); TestGuess reads the same list, so inference never demands
|
|
130
|
+
# a spec for it either.
|
|
131
|
+
canonical = ->(map) { map.transform_keys { |file| Selection::CoverageMap.canonical(file) } }
|
|
132
|
+
changed = canonical.call(maps.changed)
|
|
133
|
+
# Deleted-subject discovery (#466) probes every changed file,
|
|
134
|
+
# including the deletion-only ones stripped below for scope (#484).
|
|
135
|
+
@raw_changed_files = changed.keys
|
|
136
|
+
return [changed, canonical.call(maps.behavior)] if changed.empty?
|
|
137
|
+
|
|
138
|
+
# deletion_only_files returns canonical-absolute paths, the same
|
|
139
|
+
# space as the canonical map keys in any project-root layout.
|
|
140
|
+
deletion_only = Selection::GitDiff.deletion_only_files(@config["since"], root: @root)
|
|
141
|
+
[changed.except(*deletion_only), canonical.call(maps.behavior).except(*deletion_only)]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Changed entries of a constant hash or array literal are behavior
|
|
145
|
+
# changes no method owns (#306): the constant joins the diff scope as
|
|
146
|
+
# a subject, and the diff scope narrows its mutants to the changed
|
|
147
|
+
# entries. A changed constant assignment of an unsupported form is
|
|
148
|
+
# named, so a clean method score never implies it was tested.
|
|
149
|
+
def constant_subjects
|
|
150
|
+
finder = ConstantSubjectFinder.new
|
|
151
|
+
files_by_canonical = @files.to_h do |file|
|
|
152
|
+
[Selection::CoverageMap.canonical(Text.utf8_identity(file)), file]
|
|
153
|
+
end
|
|
154
|
+
subjects = []
|
|
155
|
+
@changed_lines.each do |canonical, lines|
|
|
156
|
+
file = files_by_canonical[canonical]
|
|
157
|
+
next unless file && canonical.end_with?(".rb")
|
|
158
|
+
|
|
159
|
+
parsed = finder.call(file)
|
|
160
|
+
parsed.unsupported.each do |found|
|
|
161
|
+
next unless found.overlaps_lines?(lines)
|
|
162
|
+
|
|
163
|
+
warn_unsupported_constant(found)
|
|
164
|
+
constant_warned_files << canonical
|
|
165
|
+
end
|
|
166
|
+
accepted = parsed.subjects.select { |subject| subject.overlaps_lines?(lines) }
|
|
167
|
+
subjects.concat(admit_replayable_file(file, accepted, canonical))
|
|
168
|
+
end
|
|
169
|
+
subjects
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A constant mutant re-evaluates the whole file with the literal
|
|
173
|
+
# changed, so whatever the file runs at load runs again on every
|
|
174
|
+
# mutant, outside the method hazard guard. saboteur only replays
|
|
175
|
+
# files whose load-time code it can vet (requires, pure defs,
|
|
176
|
+
# class/module nesting, literal assignments) and that trip no hazard
|
|
177
|
+
# denylist entry; anything else - a helper call, a registration, a
|
|
178
|
+
# write to disk - needs the explicit whole_file opt-in, which says
|
|
179
|
+
# the user accepts the replay (#319). Passing the vet is not a safety
|
|
180
|
+
# proof; it only means no unrecognized code runs at load.
|
|
181
|
+
def admit_replayable_file(file, subjects, canonical)
|
|
182
|
+
opted_in, rest = subjects.partition { |subject| whole_file_opted?(subject) }
|
|
183
|
+
return subjects if rest.empty?
|
|
184
|
+
|
|
185
|
+
guard = MutationSource::HazardGuard.new(@config["hazard"])
|
|
186
|
+
root = Prism.parse(File.binread(file)).value
|
|
187
|
+
reasons = (guard.load_time_hazards(root) + guard.unvetted_load_code(root)).uniq
|
|
188
|
+
return subjects if reasons.empty?
|
|
189
|
+
|
|
190
|
+
constant_warned_files << canonical
|
|
191
|
+
path = file.delete_prefix("#{@root}/")
|
|
192
|
+
listed = reasons.first(2).join(", ")
|
|
193
|
+
@diagnostics.warn("--since: constant subject(s) in #{path} are not mutated: the file runs code at " \
|
|
194
|
+
"load (#{listed}) that every constant mutant would run again, and saboteur " \
|
|
195
|
+
"cannot vet what re-running it does. List the constant in c.whole_file to " \
|
|
196
|
+
"replay the file anyway, or move the literal into its own file.")
|
|
197
|
+
opted_in
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The explicit opt-in for whole-file replay: subject ids or globs,
|
|
201
|
+
# matched like Execution::Reevaluator#whole_file?.
|
|
202
|
+
def whole_file_opted?(subject)
|
|
203
|
+
Array(@config["whole_file"]).any? { |pattern| File.fnmatch?(pattern.to_s, subject.id) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def warn_unsupported_constant(found)
|
|
207
|
+
path = found.file.delete_prefix("#{@root}/")
|
|
208
|
+
@diagnostics.warn("--since: changed constant #{found.id} in #{path} is not mutated: #{found.reason}. " \
|
|
209
|
+
"Only hash and array literal entries are mutated.")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The tests that can observe a constant mutant are the ones covering a
|
|
213
|
+
# read of the constant; find those reads across the include set AND the
|
|
214
|
+
# specs, which can read a constant directly (#319). A read another file
|
|
215
|
+
# captured at load time (`COPY = A::RULES`) never observes the mutant:
|
|
216
|
+
# it holds the original object. A constant with no observable read at
|
|
217
|
+
# all is dropped with a diagnostic rather than reported Survived.
|
|
218
|
+
def attach_consumers(subjects)
|
|
219
|
+
files = @files + spec_files
|
|
220
|
+
subjects.filter_map do |subject|
|
|
221
|
+
next subject unless subject.is_a?(ConstantSubject)
|
|
222
|
+
|
|
223
|
+
reads = ConstantReads.across(files, subject)
|
|
224
|
+
subject.consumer_lines = ConstantReads.observable_lines(reads, subject)
|
|
225
|
+
subject.consumer_defs = reads.transform_values(&:defs)
|
|
226
|
+
if subject.consumer_lines.empty? && reads.any? { |_file, r| r.load.any? }
|
|
227
|
+
path = subject.file.delete_prefix("#{@root}/")
|
|
228
|
+
@diagnostics.warn("--since: changed constant #{subject.id} in #{path} is not mutated: its only " \
|
|
229
|
+
"reads capture the value at load time in another file, so no test can " \
|
|
230
|
+
"observe a change to it.")
|
|
231
|
+
next
|
|
232
|
+
end
|
|
233
|
+
subject
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The specs selected for this run (inferred or explicit), as absolute
|
|
238
|
+
# paths that exist. Directory entries expand to their specs. With no
|
|
239
|
+
# tests named at all (the --all case) the configured integration's
|
|
240
|
+
# loaded spec files answer; the conventional spec tree only stands in
|
|
241
|
+
# when nothing else is known (preview, or an empty suite): a spec can
|
|
242
|
+
# read a constant directly (#319).
|
|
243
|
+
def spec_files
|
|
244
|
+
configured = @config["tests"]
|
|
245
|
+
return configured.flat_map { |entry| expand_spec_entry(entry) }.uniq unless configured.empty?
|
|
246
|
+
return Array(@test_files).uniq unless Array(@test_files).empty?
|
|
247
|
+
|
|
248
|
+
expand_spec_entry("spec")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def expand_spec_entry(entry)
|
|
252
|
+
path = File.absolute_path(entry, @root)
|
|
253
|
+
return Dir.glob(File.join(path, "**", "*_spec.rb")) if File.directory?(path)
|
|
254
|
+
return [] unless path.end_with?(".rb") && File.file?(path)
|
|
255
|
+
|
|
256
|
+
[path]
|
|
257
|
+
end
|
|
258
|
+
|
|
85
259
|
# subject.file carries the locale tag under a non-UTF-8 locale
|
|
86
260
|
# while the diff keys are UTF-8; retag losslessly before lookup so
|
|
87
261
|
# a non-ASCII path matches by its exact bytes (#231).
|
|
@@ -93,12 +267,22 @@ module Saboteur
|
|
|
93
267
|
end
|
|
94
268
|
end
|
|
95
269
|
|
|
270
|
+
# Changed files a constant literal warning already covers (#537):
|
|
271
|
+
# the umbrella counts them, but its parse/method guesses do not apply,
|
|
272
|
+
# so it says how many sit in a constant literal instead.
|
|
273
|
+
def constant_warned_files
|
|
274
|
+
@constant_warned_files ||= Set.new
|
|
275
|
+
end
|
|
276
|
+
|
|
96
277
|
# A changed Ruby file in scope whose changed lines cover no subject
|
|
97
278
|
# silently contributes nothing to the run (#232): warn, or a typo, an
|
|
98
279
|
# unparseable file or a selection bug passes as a clean selection.
|
|
99
280
|
# Comment-only changes stay quiet: their file matched subjects before
|
|
100
281
|
# drop_unchanged_syntax pruned them, and that prune is reported. A
|
|
101
282
|
# diff touching no included Ruby file is a legitimate empty scope.
|
|
283
|
+
# A file whose only finding is an unsupported or unsafe constant still
|
|
284
|
+
# counts here: the specific diagnostic says why, this says nothing ran
|
|
285
|
+
# (#295 keeps its contract).
|
|
102
286
|
def warn_changed_files_without_subjects
|
|
103
287
|
canonical = ->(file) { Selection::CoverageMap.canonical(Text.utf8_identity(file)) }
|
|
104
288
|
included = @files.map(&canonical)
|
|
@@ -108,9 +292,62 @@ module Saboteur
|
|
|
108
292
|
|
|
109
293
|
names = unmatched.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
|
|
110
294
|
more = unmatched.size > 5 ? ", ..." : ""
|
|
295
|
+
note = constant_note(unmatched)
|
|
111
296
|
@diagnostics.warn("--since: #{unmatched.size} changed Ruby file(s) have no subjects covering " \
|
|
112
|
-
"the changed lines, so nothing is mutated from them: #{names}#{more}.
|
|
113
|
-
"
|
|
297
|
+
"the changed lines, so nothing is mutated from them: #{names}#{more}. " \
|
|
298
|
+
"#{subjectless_hint(unmatched)}#{note}")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# One sentence naming how many umbrella files a constant warning
|
|
302
|
+
# already covers (#537): without it a constant edit the safety bar
|
|
303
|
+
# refuses to mutate reads like an unexplained miss in the umbrella.
|
|
304
|
+
def constant_note(unmatched)
|
|
305
|
+
constants = unmatched.count { |file| constant_warned_files.include?(file) }
|
|
306
|
+
return "" if constants.zero?
|
|
307
|
+
|
|
308
|
+
" #{constants} of these #{constants == 1 ? 'file has' : 'files have'} changes inside " \
|
|
309
|
+
"a constant literal that is not mutated (the constant warnings above name them)."
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Specs land here when one --test carries several paths; the
|
|
313
|
+
# parse/method guesses do not apply to them, so say what happened.
|
|
314
|
+
def subjectless_hint(unmatched)
|
|
315
|
+
return "These are spec files; specs are not mutated - pass them with --test." if
|
|
316
|
+
unmatched.all? { |file| file.end_with?("_spec.rb") }
|
|
317
|
+
|
|
318
|
+
embedded = unmatched.select { |file| changed_lines_inside_heredoc?(file) }
|
|
319
|
+
return "The file may not parse, or every change may sit outside a method." if embedded.empty?
|
|
320
|
+
|
|
321
|
+
names = embedded.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
|
|
322
|
+
more = embedded.size > 5 ? ", ..." : ""
|
|
323
|
+
"In #{names}#{more} the changed lines are code embedded in a string (a heredoc, often the " \
|
|
324
|
+
"argument to class_eval/module_eval or a template constant); methods defined by evaluating " \
|
|
325
|
+
"strings are not mutation subjects, so this run cannot see that behavior."
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# A heredoc body is code the static subject walk cannot see: methods
|
|
329
|
+
# defined by class_eval'ing the string, or behavior living in a
|
|
330
|
+
# template constant, exist only when the string is evaluated (#467).
|
|
331
|
+
# Changed lines inside one deserve the specific warning, not the
|
|
332
|
+
# generic parse/method guess. A file that does not parse keeps the
|
|
333
|
+
# generic hint: its problem is named there already.
|
|
334
|
+
def changed_lines_inside_heredoc?(file)
|
|
335
|
+
result = Prism.parse(File.binread(file))
|
|
336
|
+
return false if result.failure?
|
|
337
|
+
|
|
338
|
+
spans = heredoc_spans(result.value)
|
|
339
|
+
@changed_lines[file].any? { |line| spans.any? { |span| span.cover?(line) } }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def heredoc_spans(node, spans = [])
|
|
343
|
+
if (node.is_a?(Prism::StringNode) || node.is_a?(Prism::InterpolatedStringNode)) &&
|
|
344
|
+
node.opening_loc&.slice&.start_with?("<<")
|
|
345
|
+
# The node location of an interpolated string stops at the opening
|
|
346
|
+
# marker; opening through closing covers the whole heredoc body.
|
|
347
|
+
spans << (node.opening_loc.start_line..node.closing_loc.end_line)
|
|
348
|
+
end
|
|
349
|
+
node.compact_child_nodes.each { |child| heredoc_spans(child, spans) }
|
|
350
|
+
spans
|
|
114
351
|
end
|
|
115
352
|
|
|
116
353
|
# A changed Ruby file that an include pattern covers but c.exclude
|
|
@@ -141,25 +378,63 @@ module Saboteur
|
|
|
141
378
|
(territory & excluded).select { |file| file.end_with?(".rb") }.map(&canonical)
|
|
142
379
|
end
|
|
143
380
|
|
|
381
|
+
# A file the custom map marks intentionally untested ("path" => []) is
|
|
382
|
+
# never mutated: with no test there is no signal, and a guessed
|
|
383
|
+
# selection would read its empty spec list as "run the whole suite"
|
|
384
|
+
# (#369). The skip is named, never silent.
|
|
385
|
+
def drop_intentionally_untested(subjects)
|
|
386
|
+
kept, dropped = subjects.partition do |subject|
|
|
387
|
+
relative = subject.file.delete_prefix("#{@root}/")
|
|
388
|
+
@custom_map.specs_for(relative) != :excluded
|
|
389
|
+
end
|
|
390
|
+
return subjects if dropped.empty?
|
|
391
|
+
|
|
392
|
+
names = dropped.first(5).map(&:id).join(", ")
|
|
393
|
+
more = dropped.size > 5 ? ", ..." : ""
|
|
394
|
+
@diagnostics.inform("#{dropped.size} subject(s) skipped: intentionally untested in " \
|
|
395
|
+
"config/saboteur_custom_map.rb: #{names}#{more}")
|
|
396
|
+
kept
|
|
397
|
+
end
|
|
398
|
+
|
|
144
399
|
# A method whose only changes are comments, blank lines or spacing has
|
|
145
|
-
# nothing new to test.
|
|
400
|
+
# nothing new to test. A method the diff deleted is neither: it is
|
|
401
|
+
# reported as deleted, and the neighbours its lines drifted onto are
|
|
402
|
+
# skipped quietly rather than blamed for a comment-only change (#466).
|
|
146
403
|
def drop_unchanged_syntax(subjects, ref)
|
|
147
|
-
|
|
404
|
+
# No diff at all: no merge-base lookup, no report.
|
|
405
|
+
return subjects if @raw_changed_files.empty?
|
|
148
406
|
|
|
149
407
|
comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(ref, root: @root), root: @root)
|
|
408
|
+
deleted = comparison.deleted_subjects(@raw_changed_files)
|
|
409
|
+
report_deleted(deleted)
|
|
410
|
+
deleted_files = deleted.to_set { |subject| canonical_path(subject.file) }
|
|
150
411
|
same, kept = subjects.partition { |subject| comparison.unchanged?(subject) }
|
|
151
|
-
report_unchanged_syntax(same)
|
|
412
|
+
report_unchanged_syntax(same.reject { |subject| deleted_files.include?(canonical_path(subject.file)) })
|
|
152
413
|
kept
|
|
153
414
|
end
|
|
154
415
|
|
|
416
|
+
# Names at most five, like the other selection notes.
|
|
417
|
+
def report_deleted(deleted)
|
|
418
|
+
return if deleted.empty?
|
|
419
|
+
|
|
420
|
+
names = deleted.first(5).map(&:id).join(", ")
|
|
421
|
+
more = deleted.size > 5 ? ", ..." : ""
|
|
422
|
+
verb = deleted.one? ? "subject was" : "subjects were"
|
|
423
|
+
@diagnostics.inform("#{deleted.size} changed #{verb} deleted, nothing to mutate: #{names}#{more}")
|
|
424
|
+
end
|
|
425
|
+
|
|
155
426
|
def report_unchanged_syntax(subjects)
|
|
156
427
|
return if subjects.empty?
|
|
157
428
|
|
|
158
429
|
names = subjects.first(5).map(&:id).join(", ")
|
|
159
430
|
more = subjects.size > 5 ? ", ..." : ""
|
|
160
|
-
@diagnostics.inform("#{subjects.size} changed
|
|
431
|
+
@diagnostics.inform("#{subjects.size} changed subject(s) differ only in comments or formatting, " \
|
|
161
432
|
"so they are skipped: #{names}#{more}")
|
|
162
433
|
end
|
|
434
|
+
|
|
435
|
+
def canonical_path(file)
|
|
436
|
+
Selection::CoverageMap.canonical(Text.utf8_identity(file))
|
|
437
|
+
end
|
|
163
438
|
end
|
|
164
439
|
end
|
|
165
440
|
end
|