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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +657 -0
  3. data/README.md +198 -1077
  4. data/lib/saboteur/cli/exit_status.rb +32 -1
  5. data/lib/saboteur/cli/help_options.rb +2 -1
  6. data/lib/saboteur/cli/history_command.rb +135 -0
  7. data/lib/saboteur/cli/presets.rb +6 -2
  8. data/lib/saboteur/cli/selection_options.rb +31 -0
  9. data/lib/saboteur/cli/shard_options.rb +17 -0
  10. data/lib/saboteur/cli/source_set.rb +53 -0
  11. data/lib/saboteur/cli/survivors_command.rb +157 -0
  12. data/lib/saboteur/cli.rb +71 -54
  13. data/lib/saboteur/config.rb +98 -5
  14. data/lib/saboteur/config_file.rb +8 -0
  15. data/lib/saboteur/constant_reads.rb +182 -0
  16. data/lib/saboteur/constant_subject.rb +70 -0
  17. data/lib/saboteur/constant_subject_finder.rb +281 -0
  18. data/lib/saboteur/coverage_reads.rb +29 -0
  19. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  20. data/lib/saboteur/doctor.rb +9 -0
  21. data/lib/saboteur/engine/baseline_guard.rb +62 -14
  22. data/lib/saboteur/engine/boot.rb +4 -0
  23. data/lib/saboteur/engine/candidate_selection.rb +19 -0
  24. data/lib/saboteur/engine/candidates.rb +43 -3
  25. data/lib/saboteur/engine/coverage_store.rb +32 -10
  26. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  27. data/lib/saboteur/engine/diagnostics.rb +18 -0
  28. data/lib/saboteur/engine/distribution.rb +10 -0
  29. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  30. data/lib/saboteur/engine/gated_run.rb +35 -0
  31. data/lib/saboteur/engine/load_coverage.rb +45 -0
  32. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  33. data/lib/saboteur/engine/pipelines.rb +107 -0
  34. data/lib/saboteur/engine/result_cache.rb +65 -8
  35. data/lib/saboteur/engine/run_budget.rb +27 -0
  36. data/lib/saboteur/engine/sandbox_policy.rb +38 -0
  37. data/lib/saboteur/engine/scheduler.rb +109 -3
  38. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  39. data/lib/saboteur/engine/shard.rb +69 -0
  40. data/lib/saboteur/engine/subject_selection.rb +295 -20
  41. data/lib/saboteur/engine/test_guess.rb +315 -25
  42. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  43. data/lib/saboteur/engine.rb +100 -127
  44. data/lib/saboteur/execution/external_runner.rb +471 -0
  45. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  46. data/lib/saboteur/execution/peak_memory.rb +28 -0
  47. data/lib/saboteur/execution/process_table.rb +12 -0
  48. data/lib/saboteur/execution/process_tree.rb +65 -5
  49. data/lib/saboteur/execution/reevaluator.rb +68 -12
  50. data/lib/saboteur/execution/runner.rb +46 -7
  51. data/lib/saboteur/execution/sandbox.rb +15 -2
  52. data/lib/saboteur/execution/seatbelt.rb +226 -0
  53. data/lib/saboteur/execution/supervisor.rb +27 -10
  54. data/lib/saboteur/execution/worker_pool.rb +23 -1
  55. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  56. data/lib/saboteur/gates.rb +43 -2
  57. data/lib/saboteur/help.rb +60 -9
  58. data/lib/saboteur/ignore_list.rb +29 -6
  59. data/lib/saboteur/integration/rspec.rb +135 -5
  60. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  61. data/lib/saboteur/mutation_source/builtin.rb +26 -5
  62. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  63. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  64. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  65. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  67. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  68. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  69. data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
  70. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  71. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  72. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  73. data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
  74. data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
  75. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  76. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  77. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  79. data/lib/saboteur/preview.rb +120 -12
  80. data/lib/saboteur/refinement_activations.rb +60 -0
  81. data/lib/saboteur/reporter/agent.rb +9 -1
  82. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  83. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  84. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  85. data/lib/saboteur/reporter/findings.rb +5 -2
  86. data/lib/saboteur/reporter/history_policy.rb +11 -0
  87. data/lib/saboteur/reporter/history_reader.rb +47 -0
  88. data/lib/saboteur/reporter/html.rb +26 -4
  89. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  90. data/lib/saboteur/reporter/stryker.rb +27 -3
  91. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  92. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  93. data/lib/saboteur/reporter/terminal.rb +40 -7
  94. data/lib/saboteur/result.rb +19 -3
  95. data/lib/saboteur/selection/coverage_map.rb +105 -10
  96. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  97. data/lib/saboteur/selection/external_test_map.rb +27 -0
  98. data/lib/saboteur/selection/git_diff.rb +93 -11
  99. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  100. data/lib/saboteur/selection/recorder.rb +22 -4
  101. data/lib/saboteur/selection/source_scope.rb +22 -0
  102. data/lib/saboteur/subject.rb +42 -7
  103. data/lib/saboteur/subject_finder.rb +173 -29
  104. data/lib/saboteur/version.rb +1 -1
  105. data/lib/saboteur/working_tree.rb +38 -15
  106. data/lib/saboteur.rb +13 -0
  107. 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
- def self.files_matching(root, files, patterns)
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
- InlineSuppression.new(file, File.read(file))
77
+ source = File.read(file)
78
+ InlineSuppression.new(file, source)
56
79
  parsed = finder.call(file)
57
- @diagnostics.record_discovery_failure(file, reason: "invalid_file_syntax") unless parsed.errors.empty?
58
- parsed.errors.first(1).each do |message|
59
- @diagnostics.warn("#{file.delete_prefix("#{@root}/")} does not parse: #{message}")
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.each do |pattern|
67
- next if subjects.any? { |subject| self.class.match?(subject.id, pattern) }
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}. The " \
113
- "file may not parse, or every change may sit outside a method.")
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
- return subjects if subjects.empty?
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 method(s) differ only in comments or formatting, " \
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