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
@@ -6,7 +6,9 @@ require "pathname"
6
6
  module Saboteur
7
7
  class Engine
8
8
  # The specs that sit next to the mutated source files by
9
- # naming convention (lib/a/b.rb -> spec/a/b_spec.rb). A guess, not a
9
+ # naming convention (lib/a/b.rb -> spec/a/b_spec.rb), or the explicit
10
+ # mapping from config/saboteur_custom_map.rb for layouts the convention
11
+ # cannot guess (#369). A guess, not a
10
12
  # coverage fact: a spec that only reaches the file through another one
11
13
  # is not found. Packwerk packs (a directory with a package.yml) map to the
12
14
  # spec directory inside the same pack, and controllers also map to request
@@ -19,39 +21,118 @@ module Saboteur
19
21
  # not exist; that is why the file is unmatched), for the stop-before-boot
20
22
  # error. The result also builds its own messages, so a stubbed guess in
21
23
  # the engine's specs formats exactly like a real one.
22
- Result = Data.define(:specs, :unmatched, :subjectless, :changed, :hints) do
24
+ # changed_specs: spec files the diff adds or changes that run, already
25
+ # in specs; skipped_specs: spec files the diff touches that cannot run,
26
+ # each with the reason.
27
+ Result = Data.define(:specs, :unmatched, :subjectless, :changed, :hints, :changed_specs,
28
+ :skipped_specs, :out_of_scope, :excluded, :near) do
29
+ def initialize(changed_specs: [], skipped_specs: {}, out_of_scope: [], excluded: [], near: {}, **fields)
30
+ super
31
+ end
32
+
23
33
  # The stop-before-boot usage error: names each expected spec and the
24
34
  # ways out as an append-only instruction - never a reconstructed
25
35
  # command, which could carry a hostile ref or a wrong source scope.
36
+ # When the diff deleted the expected spec or added others, it says
37
+ # so: a renamed spec is the usual cause.
26
38
  def unmatched_message
27
- pairs = unmatched.map { |file| "#{file} (expected #{hints.fetch(file)})" }
28
- "no spec found for #{pairs.join(', ')}, so nothing ran. Re-run the same command with --test " \
29
- "followed by your existing spec paths, or append --all to use the whole suite without " \
39
+ pairs = unmatched.map { |file| pair_description(file) }
40
+ changed = changed_specs.any? ? " Specs the diff adds or changes: #{changed_specs.join(', ')}." : ""
41
+ "no spec found for #{pairs.join(', ')}, so nothing ran.#{changed} Re-run the same command with " \
42
+ "one --test per existing spec path, or append --all to use the whole suite without " \
30
43
  "changing the source scope."
31
44
  end
32
45
 
46
+ private
47
+
48
+ def pair_description(file)
49
+ expected = hints.fetch(file)
50
+ gone = skipped_specs[expected] == DELETED ? ", deleted in the diff" : ""
51
+ found = near.fetch(file, []).any? ? "; found: #{near.fetch(file).join(', ')}" : ""
52
+ "#{file} (expected #{expected}#{gone}#{found})"
53
+ end
54
+
55
+ public
56
+
57
+ # The spec files the diff touched that do not run, with the reasons.
58
+ def skipped_message
59
+ named = skipped_specs.map { |path, reason| "#{path} (#{reason})" }
60
+ "inferred specs: not running spec files changed in the diff: #{named.join(', ')}"
61
+ end
62
+
33
63
  # Why the subjectless files needed no spec.
34
64
  def subjectless_message(since:)
35
65
  detail = since ? "no changed mutation subjects" : "no mutation subjects"
36
66
  "inferred specs: no spec is required for #{subjectless.join(', ')}: they have #{detail}"
37
67
  end
38
68
 
69
+ # The per-file warning when the run proceeds with the mapped
70
+ # subset (#370): the file's mutants run only against the inferred
71
+ # specs, so lines nothing else covers surface as NoCoverage.
72
+ def unmatched_warning(file)
73
+ "inferred specs: no spec found for #{pair_description(file)}; its mutants run only " \
74
+ "against the inferred specs and a line they miss is reported as NoCoverage"
75
+ end
76
+
77
+ # The stop for a --since run whose changed source files all sit outside the
78
+ # include scope (#483): falling back to the whole suite would run the slowest
79
+ # possible suite against zero subjects, so the run refuses and names what the
80
+ # scope hid. Append-only recovery, like unmatched_message: extend include, or
81
+ # name the specs with --test.
82
+ def out_of_scope_message
83
+ named = out_of_scope.first(10).join(", ")
84
+ more = out_of_scope.size - 10
85
+ named += " and #{more} more" if more.positive?
86
+ "no changed source file lies inside the include scope, so nothing ran. " \
87
+ "Changed source files outside it: #{named}. Extend include to cover them, " \
88
+ "or re-run the same command with one --test per existing spec path."
89
+ end
90
+
91
+ # Why an empty guess mutates nothing rather than widening the run
92
+ # to the whole suite (#369).
93
+ def no_specs_message(since:)
94
+ return "#{excluded_message}, so there is nothing to mutate" if excluded.any?
95
+
96
+ if since
97
+ "inferred specs: no changed file has a mutation subject on its changed lines, " \
98
+ "so there is nothing to mutate"
99
+ else
100
+ "inferred specs: no source file has a mutation subject, so there is nothing to mutate"
101
+ end
102
+ end
103
+
104
+ # Why the custom-map-excluded files needed no spec (#369).
105
+ def excluded_message
106
+ "inferred specs: no spec is required for #{excluded.join(', ')}: " \
107
+ "intentionally untested in config/saboteur_custom_map.rb"
108
+ end
109
+
39
110
  # The warning for a guess-scoped run: the guessed specs now stand as
40
111
  # explicit tests, with the cross-file coverage caveat that brings.
41
112
  def scope_message(since:)
42
- what = since ? "a changed file" : "a source file"
43
- "inferred specs: running only #{specs.join(', ')}. A spec that reaches " \
44
- "#{what} only through another file does not run, so a mutant only it would kill can " \
45
- "survive, and a line no inferred spec executes is reported as NoCoverage"
113
+ who = since ? SINCE_CAVEAT_SUBJECT : CAVEAT_SUBJECT
114
+ added = changed_specs.any? ? " (changed in the diff: #{changed_specs.join(', ')})" : ""
115
+ "inferred specs: running only #{specs.join(', ')}#{added}. #{who} only through another file " \
116
+ "does not run, so a mutant only it would kill can survive, and a line no inferred spec " \
117
+ "executes is reported as NoCoverage"
46
118
  end
47
119
  end
48
120
 
121
+ CAVEAT_SUBJECT = "A spec that reaches a source file"
122
+ SINCE_CAVEAT_SUBJECT = "A spec the diff did not change that reaches a changed file"
49
123
  SOURCE_ROOTS = %w[lib app].freeze
124
+ DELETED = "deleted in the diff"
125
+ OUTSIDE_SUITE = "not loaded by the configured RSpec suite: its pattern, exclude pattern or default path " \
126
+ "leaves it out"
127
+ NOT_REGULAR = "not a regular file"
50
128
 
51
- def initialize(config, diagnostics: nil)
129
+ def initialize(config, diagnostics: nil, custom_map: nil)
52
130
  @diagnostics = diagnostics
53
- @root = File.realpath(config.root)
131
+ # Retagged losslessly like Config#root, so a join with a UTF-8 git
132
+ # path never mixes encodings whatever the locale tagged it with.
133
+ @root = Text.utf8_identity(File.realpath(config.root))
54
134
  @config = config
135
+ @custom_map = custom_map || CustomMap.load(config.path("custom_map"), root: config.root)
55
136
  end
56
137
 
57
138
  def call
@@ -71,10 +152,13 @@ module Saboteur
71
152
  end
72
153
 
73
154
  # specs: existing spec files (relative paths) guessed for the files in
74
- # scope; unmatched: such files with no guessed spec; subjectless: files
75
- # with no mutation subject the run checks, which neither need nor get a
76
- # spec; changed: how many source files are in scope; hints: each
77
- # unmatched file's conventional spec path.
155
+ # scope, plus every spec file the diff adds or changes (#307): a pull
156
+ # request often tests a change through a request or controller spec
157
+ # whose name maps to no changed file. unmatched: such files with no
158
+ # guessed spec; subjectless: files with no mutation subject the run
159
+ # checks, which neither need nor get a spec; changed: how many source
160
+ # files are in scope; hints: each unmatched file's conventional spec
161
+ # path.
78
162
  def call_diff
79
163
  lines_by_file = changed_lines_by_file
80
164
  subjectless = subjectless_files(lines_by_file)
@@ -82,7 +166,9 @@ module Saboteur
82
166
  # subjectless file mutates nothing, so its spec would only narrow the
83
167
  # run for no reason.
84
168
  guesses = lines_by_file.except(*subjectless).to_h { |file| [file, guess_for(file)] }
85
- result(guesses, subjectless, lines_by_file.size)
169
+ # Nothing in scope: name the changed source the include hid (#483).
170
+ out_of_scope = lines_by_file.empty? ? out_of_scope_source_files : []
171
+ result(guesses, subjectless, lines_by_file.size, out_of_scope: out_of_scope)
86
172
  end
87
173
 
88
174
  # Without --since every included file is in scope: a file needs its
@@ -110,11 +196,12 @@ module Saboteur
110
196
  lines_by_file = changed_lines_by_file
111
197
  comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(@config["since"], root: @root), root: @root)
112
198
  finder = SubjectFinder.new
199
+ constant_finder = ConstantSubjectFinder.new
113
200
  files.select do |file|
114
201
  lines = lines_by_file[file]
115
202
  next false unless lines
116
203
 
117
- finder.call(File.join(@root, file)).subjects.any? do |subject|
204
+ subjects_in(finder, constant_finder, file).any? do |subject|
118
205
  patterns.any? { |pattern| SubjectSelection.match?(subject.id, pattern) } &&
119
206
  subject.overlaps_lines?(lines) &&
120
207
  !comparison.unchanged?(subject)
@@ -122,13 +209,146 @@ module Saboteur
122
209
  end
123
210
  end
124
211
 
125
- def result(guesses, subjectless, changed)
126
- unmatched = guesses.select { |_, spec| spec.nil? }.keys
127
- Result.new(specs: guesses.values.compact.uniq.sort,
212
+ # Changed specs join only a run that mutates something: with no file
213
+ # in scope there is nothing for them to kill.
214
+ def result(guesses, subjectless, changed, out_of_scope: [])
215
+ unmatched = guesses.select { |_, specs| specs.nil? }.keys
216
+ excluded = guesses.select { |_, specs| specs == :excluded }.keys.sort
217
+ inferred = guesses.values.grep(Array).flatten
218
+ diff = @config["since"] && guesses.any? ? specs_in_diff(inferred) : { run: [], skipped: {} }
219
+ unmatched -= covered_by_changed_specs(unmatched, diff[:run])
220
+ Result.new(specs: (inferred + diff[:run]).uniq.sort,
128
221
  unmatched: unmatched,
129
222
  subjectless: subjectless,
130
223
  changed: changed,
131
- hints: unmatched.to_h { |file| [file, candidates_for(file).first] })
224
+ hints: unmatched.to_h { |file| [file, expected_spec(file, diff[:skipped])] },
225
+ near: unmatched.to_h { |file| [file, nearby_specs(file)] },
226
+ changed_specs: diff[:run],
227
+ skipped_specs: diff[:skipped],
228
+ out_of_scope: out_of_scope,
229
+ excluded: excluded)
230
+ end
231
+
232
+ # Changed Ruby files the include scope hides (#483), relative to the
233
+ # root: the diff touched source, but none of it lands inside include.
234
+ # A changed spec is a test, not hidden source; a spec-only diff keeps
235
+ # the whole-suite fallback it always had.
236
+ def out_of_scope_source_files
237
+ included = included_files.to_set
238
+ Selection::GitDiff.changed_paths(@config["since"], root: @root)[:present]
239
+ .select { |path| path.end_with?(".rb") && !spec?(path) && !included.include?(path) }
240
+ end
241
+
242
+ # Spec files under the root that the diff since the base adds, changes
243
+ # or deletes. A changed file runs when the configured suite loads it
244
+ # and it is a regular file; RSpec applies its pattern only to the
245
+ # directories it scans, never to a file named as a test, so the suite's
246
+ # own list decides (a fixture under an excluded directory must not
247
+ # run). A changed *_spec.rb that cannot run is named with the reason.
248
+ # An inferred spec is left to inference, which runs it as before
249
+ # #307, so it is neither added again nor reported as skipped.
250
+ def specs_in_diff(inferred)
251
+ paths = Selection::GitDiff.changed_paths(@config["since"], root: @root)
252
+ skipped = paths[:deleted].select { |path| spec?(path) }.to_h { |path| [path, DELETED] }
253
+ candidates = paths[:present] - inferred
254
+ run = candidates.select { |path| spec?(path) || in_suite?(path) }.select do |path|
255
+ reason = unrunnable_reason(path)
256
+ skipped[path] = reason if reason
257
+ reason.nil?
258
+ end
259
+ { run: run, skipped: skipped.sort.to_h }
260
+ end
261
+
262
+ # A consolidated spec (claims_spec.rb style) never matches the
263
+ # per-file guess, but when the diff itself changes that spec and the
264
+ # spec reads a constant the unmatched file defines, the file is
265
+ # covered: the spec already runs via changed_specs, so stopping would
266
+ # block a run that had something to kill (#538). A mutable change no
267
+ # changed spec exercises still stops.
268
+ def covered_by_changed_specs(unmatched, changed_specs)
269
+ return [] if unmatched.empty? || changed_specs.empty?
270
+
271
+ specs = changed_specs.map { |path| File.join(@root, path) }
272
+ unmatched.select do |file|
273
+ defined_constants(file).any? { |constant| ConstantReads.across(specs, constant).any? }
274
+ end
275
+ end
276
+
277
+ # The constants a file defines: owners of its def subjects
278
+ # ("JWT::Claims::DecodeVerifier" from "...#call") and its constant
279
+ # literal subjects. Shaped for ConstantReads, which matches a bare
280
+ # read under the owner's scope or the constant's full path.
281
+ DefinedConstant = Data.define(:name, :owner, :id)
282
+
283
+ def defined_constants(file)
284
+ path = File.join(@root, file)
285
+ owners = SubjectFinder.new.call(path).subjects.filter_map { |subject| subject.id.split(/[#.]/).first }
286
+ literals = ConstantSubjectFinder.new.call(path).subjects.map(&:id)
287
+ (owners + literals).uniq.map do |id|
288
+ owner, _, name = id.rpartition("::")
289
+ DefinedConstant.new(name: name.to_sym, owner: owner.empty? ? nil : owner, id: id)
290
+ end
291
+ end
292
+
293
+ # The spec the stop names for an unmatched file: a candidate the diff
294
+ # deleted (a renamed request spec is not always the first), else the
295
+ # most specific one.
296
+ def expected_spec(file, skipped)
297
+ candidates = candidates_for(file)
298
+ candidates.find { |path| skipped[path] == DELETED } || candidates.first
299
+ end
300
+
301
+ # Existing spec files with the same basename anywhere under the spec
302
+ # root: the stop and the warning point at them so the user can
303
+ # --test without hunting (#370). Bounded; computed only for
304
+ # unmatched files, which are about to stop or warn.
305
+ def nearby_specs(file)
306
+ # The basename is data, not a pattern: [, ], {, }, ?, * and \ are
307
+ # glob metacharacters and must be escaped to match literally.
308
+ basename = File.basename(file, ".rb").gsub(/[\[\]{}?*\\]/) { |char| "\\#{char}" }
309
+ Dir.glob("spec/**/#{basename}_spec.rb", base: @root)
310
+ .select { |path| File.file?(File.join(@root, path)) }.sort.first(3)
311
+ end
312
+
313
+ # lstat, not stat: a symlink can point anywhere, including a spec of
314
+ # another application or a fixture the suite excludes.
315
+ def unrunnable_reason(path)
316
+ return NOT_REGULAR unless File.lstat(File.join(@root, path)).file?
317
+ return @suite_error if suite_files.nil?
318
+
319
+ OUTSIDE_SUITE unless in_suite?(path)
320
+ rescue SystemCallError
321
+ NOT_REGULAR
322
+ end
323
+
324
+ # macOS git reports a precomposed (NFC) name, while the directory
325
+ # entry RSpec lists can be decomposed (NFD): the same spec spelled two
326
+ # ways. Valid UTF-8 compares in NFC (unicode_normalize's default);
327
+ # other bytes compare as they are.
328
+ def in_suite?(path)
329
+ suite = suite_files
330
+ suite ? suite.include?(comparable(path)) : false
331
+ end
332
+
333
+ def comparable(path)
334
+ path.valid_encoding? ? path.unicode_normalize : path
335
+ end
336
+
337
+ # nil when RSpec cannot list the suite: under a non-UTF-8 locale its
338
+ # own path expansion can mix encodings for a non-ASCII root. A changed
339
+ # spec is then not run, never guessed into the run.
340
+ def suite_files
341
+ return @suite_files if defined?(@suite_files)
342
+
343
+ listed = Integration.fetch(@config["integration"]).new(@config).configured_spec_files(@root)
344
+ @suite_files = listed.to_set { |path| comparable(path) }
345
+ rescue EncodingError => e
346
+ @suite_error = "the configured RSpec suite's files could not be listed (#{e.class})"
347
+ @suite_files = nil
348
+ end
349
+
350
+ def spec?(path)
351
+ path.end_with?("_spec.rb")
132
352
  end
133
353
 
134
354
  # Included files relative to the root, sorted for stable output.
@@ -157,15 +377,67 @@ module Saboteur
157
377
  def subjectless_files(lines_by_file)
158
378
  comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(@config["since"], root: @root), root: @root)
159
379
  finder = SubjectFinder.new
380
+ constant_finder = ConstantSubjectFinder.new
381
+ # A file whose diff only deletes lines has no new code to mutate:
382
+ # the deletion is the change the suite itself covers. Its marked
383
+ # neighbor lines would otherwise demand a spec the file never had
384
+ # (#484). Subject selection reads the same list, so both layers
385
+ # agree: no subject, no mutant, no spec demand. The list is
386
+ # canonical-absolute; the line map's keys are root-relative, so
387
+ # compare in absolute space (a monorepo root below the git
388
+ # toplevel must not silently disable this).
389
+ deletion_only = Selection::GitDiff.deletion_only_files(@config["since"], root: @root)
160
390
  lines_by_file.reject do |file, lines|
161
- subjects = finder.call(File.join(@root, file)).subjects
162
- subjects.select { |subject| subject.overlaps_lines?(lines) }
163
- .any? { |subject| !comparison.unchanged?(subject) }
391
+ # (reject keeps the files WITH a changed subject; subjectless
392
+ # is what remains)
393
+ next false if deletion_only.include?(File.join(@root, file))
394
+
395
+ subjects_in(finder, constant_finder, file)
396
+ .select { |subject| subject.overlaps_lines?(lines) }
397
+ .any? { |subject| !comparison.unchanged?(subject) }
164
398
  end.keys
165
399
  end
166
400
 
401
+ # Def subjects and changed constant literal subjects (#306): both
402
+ # need a spec to run against. Only the --since route asks.
403
+ def subjects_in(finder, constant_finder, file)
404
+ path = File.join(@root, file)
405
+ finder.call(path).subjects + constant_finder.call(path).subjects
406
+ end
407
+
408
+ # The custom map wins over the naming convention: it exists for the
409
+ # layouts the convention cannot guess (#369). Its entries come back as
410
+ # an array of spec paths, or :excluded for a file the map marks
411
+ # intentionally untested - such a file needs no guessed spec, so it is
412
+ # neither unmatched nor given one. Returns the guessed spec paths, nil
413
+ # when neither the map nor the convention names an existing spec.
167
414
  def guess_for(file)
168
- candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
415
+ mapped = @custom_map.specs_for(file)
416
+ return :excluded if mapped == :excluded
417
+ return expand_mapped(file, mapped) if mapped
418
+
419
+ conventional = candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
420
+ conventional && [conventional]
421
+ end
422
+
423
+ # A mapped spec can be a glob (expanded against the root) or a regexp
424
+ # backreference filled at match time, and neither is validated to
425
+ # exist when the map loads. Only specs that exist run; the literal
426
+ # glob string never reaches RSpec. When the winning entry's specs all
427
+ # vanish the mapping is broken: stop naming it rather than silently
428
+ # guessing on and running the wrong specs (#369).
429
+ def expand_mapped(file, specs)
430
+ expanded = specs.flat_map do |spec|
431
+ if spec.match?(/[*?\[{]/)
432
+ Dir.glob(File.join(@root, spec)).map { |path| path.delete_prefix("#{@root}/") }
433
+ else
434
+ [spec]
435
+ end
436
+ end
437
+ expanded = expanded.select { |path| File.file?(File.join(@root, path)) }
438
+ return expanded unless expanded.empty?
439
+
440
+ raise ConfigError, "custom map entry for #{file} names spec(s) #{specs.join(', ')}, which match no file"
169
441
  end
170
442
 
171
443
  # Candidate specs for a source file, most specific first: the nearest
@@ -181,9 +453,27 @@ module Saboteur
181
453
  rooted = rest.size == 2 && SOURCE_ROOTS.include?(rest.first)
182
454
  names = rooted ? [rest.last, "lib/#{rest.last}", stem] : [stem]
183
455
  names = names.flat_map { |name| [name, *request_names(name)] }.uniq
456
+ # A dashed gem name keeps dashes under lib/ but its specs live under
457
+ # the underscored require name: lib/acts-as-taggable-on/x.rb tests in
458
+ # spec/acts_as_taggable_on/x_spec.rb. The literal name still wins
459
+ # when both exist (#383).
460
+ names = names.flat_map { |name| [name, name.tr("-", "_")] }.uniq
461
+ names = layout_variants(names) if rooted
184
462
  names.map { |name| "#{spec_root}/#{name}_spec.rb" }
185
463
  end
186
464
 
465
+ # Layouts that mirror less of the source path, after every mirrored
466
+ # guess (#394): a spec/unit/ prefix (shoulda-matchers mirrors below
467
+ # it, #393), the basename at the spec root (pundit), and both
468
+ # together (aasm, #370). Weaker guesses come last: the first
469
+ # candidate that exists wins. A file outside the source roots
470
+ # keeps its literal guess only: dropping directories from a path
471
+ # the roots never anchored would guess any same-named spec.
472
+ def layout_variants(names)
473
+ flat = names.map { |name| name.split("/").last }.uniq
474
+ (names + names.map { |name| "unit/#{name}" } + flat + flat.map { |name| "unit/#{name}" }).uniq
475
+ end
476
+
187
477
  # Rails tests controllers as request specs: spec/requests/users_spec.rb
188
478
  # or spec/requests/users_controller_spec.rb for controllers/users_controller.rb.
189
479
  def request_names(name)
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "prism"
5
5
  require_relative "../inline_suppression"
6
+ require_relative "../constant_subject_finder"
6
7
 
7
8
  module Saboteur
8
9
  class Engine
@@ -37,6 +38,18 @@ module Saboteur
37
38
  self.class.digest(before.node) == self.class.digest(subject.node)
38
39
  end
39
40
 
41
+ # Subjects the diff deleted from a changed file: defined once at the
42
+ # base of the --since ref and gone from the working tree. A method
43
+ # that moved but still exists keeps its id and is not a deletion.
44
+ def deleted_subjects(files)
45
+ files.flat_map do |file|
46
+ next [] unless file.end_with?(".rb") && File.file?(file)
47
+
48
+ current_ids = unique_by_id(File.read(file), file).keys
49
+ base_subjects(file).values.reject { |subject| current_ids.include?(subject.id) }
50
+ end
51
+ end
52
+
40
53
  # The node as plain data: type and fields, never locations (except
41
54
  # the line a `__LINE__` stands for).
42
55
  def self.digest(value)
@@ -64,9 +77,11 @@ module Saboteur
64
77
  end
65
78
 
66
79
  # Only the names defined once: with two defs of a name, which one a
67
- # change belongs to is a guess.
80
+ # change belongs to is a guess. Constant literal subjects compare the
81
+ # same way (#306): a comment-only change to the literal prunes them.
68
82
  def unique_by_id(source, file)
69
- subjects = SubjectFinder.new.call(file, source: source).subjects
83
+ subjects = SubjectFinder.new.call(file, source: source).subjects +
84
+ ConstantSubjectFinder.new.call(file, source: source).subjects
70
85
  counts = subjects.map(&:id).tally
71
86
  subjects.select { |subject| counts[subject.id] == 1 }.to_h { |subject| [subject.id, subject] }
72
87
  end