saboteur 0.8.0 → 0.9.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +44 -6
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/attribute_finder.rb +116 -0
  6. data/lib/saboteur/baseline/run_evidence.rb +2 -1
  7. data/lib/saboteur/cli/agent_provenance.rb +59 -0
  8. data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
  9. data/lib/saboteur/cli/doctor_command.rb +41 -0
  10. data/lib/saboteur/cli/error_cause.rb +40 -0
  11. data/lib/saboteur/cli/execution_options.rb +46 -0
  12. data/lib/saboteur/cli/exit_status.rb +12 -14
  13. data/lib/saboteur/cli/explicit_file_options.rb +21 -0
  14. data/lib/saboteur/cli/explicit_sources.rb +46 -0
  15. data/lib/saboteur/cli/failure_output.rb +35 -0
  16. data/lib/saboteur/cli/help_command.rb +23 -0
  17. data/lib/saboteur/cli/matrix_option.rb +17 -0
  18. data/lib/saboteur/cli/output_options.rb +27 -0
  19. data/lib/saboteur/cli/presets.rb +41 -1
  20. data/lib/saboteur/cli/preview_command.rb +26 -0
  21. data/lib/saboteur/cli/run_budget_options.rb +6 -1
  22. data/lib/saboteur/cli/run_command.rb +33 -0
  23. data/lib/saboteur/cli/run_output.rb +38 -0
  24. data/lib/saboteur/cli/run_reporting.rb +6 -5
  25. data/lib/saboteur/cli/run_validation.rb +54 -0
  26. data/lib/saboteur/cli/source_set.rb +0 -18
  27. data/lib/saboteur/cli.rb +29 -305
  28. data/lib/saboteur/config.rb +76 -7
  29. data/lib/saboteur/config_file.rb +12 -8
  30. data/lib/saboteur/constant_subject_finder.rb +3 -0
  31. data/lib/saboteur/custom_map.rb +24 -5
  32. data/lib/saboteur/deep_source.rb +49 -0
  33. data/lib/saboteur/dirty_tree_warning.rb +6 -4
  34. data/lib/saboteur/doctor.rb +10 -0
  35. data/lib/saboteur/engine/baseline_guard.rb +118 -15
  36. data/lib/saboteur/engine/baseline_parts.rb +51 -0
  37. data/lib/saboteur/engine/boot.rb +11 -3
  38. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  39. data/lib/saboteur/engine/candidate_selection.rb +16 -5
  40. data/lib/saboteur/engine/candidates.rb +4 -1
  41. data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
  42. data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
  43. data/lib/saboteur/engine/coverage_partial.rb +48 -0
  44. data/lib/saboteur/engine/coverage_parts.rb +50 -0
  45. data/lib/saboteur/engine/coverage_shadow.rb +92 -0
  46. data/lib/saboteur/engine/coverage_store.rb +296 -21
  47. data/lib/saboteur/engine/diagnostics.rb +24 -7
  48. data/lib/saboteur/engine/distribution.rb +2 -2
  49. data/lib/saboteur/engine/gated_run.rb +4 -1
  50. data/lib/saboteur/engine/load_coverage.rb +72 -8
  51. data/lib/saboteur/engine/partial_invalidation.rb +115 -0
  52. data/lib/saboteur/engine/partial_merge.rb +40 -0
  53. data/lib/saboteur/engine/pipelines.rb +11 -3
  54. data/lib/saboteur/engine/reference_specs.rb +71 -0
  55. data/lib/saboteur/engine/result_cache.rb +68 -5
  56. data/lib/saboteur/engine/resume_manifest.rb +110 -0
  57. data/lib/saboteur/engine/run_budget.rb +10 -7
  58. data/lib/saboteur/engine/run_fingerprint.rb +2 -1
  59. data/lib/saboteur/engine/sandbox_policy.rb +26 -5
  60. data/lib/saboteur/engine/scheduler.rb +33 -25
  61. data/lib/saboteur/engine/selection_announcement.rb +9 -3
  62. data/lib/saboteur/engine/shadow_comparison.rb +121 -0
  63. data/lib/saboteur/engine/shadow_run.rb +38 -0
  64. data/lib/saboteur/engine/subject_selection.rb +144 -64
  65. data/lib/saboteur/engine/suite_partition.rb +22 -0
  66. data/lib/saboteur/engine/test_guess.rb +186 -32
  67. data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
  68. data/lib/saboteur/engine.rb +44 -11
  69. data/lib/saboteur/environment_hints.rb +55 -0
  70. data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
  71. data/lib/saboteur/execution/external_runner.rb +123 -1
  72. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  73. data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
  74. data/lib/saboteur/execution/marker_sweep.rb +2 -1
  75. data/lib/saboteur/execution/process_table.rb +7 -3
  76. data/lib/saboteur/execution/reevaluator.rb +7 -0
  77. data/lib/saboteur/execution/sandbox.rb +39 -20
  78. data/lib/saboteur/execution/seatbelt.rb +10 -3
  79. data/lib/saboteur/execution/supervisor.rb +42 -8
  80. data/lib/saboteur/execution/supervisor_batch.rb +105 -0
  81. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  82. data/lib/saboteur/execution/worker_pool.rb +48 -4
  83. data/lib/saboteur/frameworks/rails.rb +2 -5
  84. data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
  85. data/lib/saboteur/gates.rb +121 -28
  86. data/lib/saboteur/install.rb +6 -1
  87. data/lib/saboteur/integration/base.rb +19 -2
  88. data/lib/saboteur/integration/rspec.rb +145 -18
  89. data/lib/saboteur/line_hits.rb +81 -0
  90. data/lib/saboteur/method_calls.rb +91 -0
  91. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  92. data/lib/saboteur/mutation_source/builtin.rb +33 -21
  93. data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
  94. data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
  95. data/lib/saboteur/mutation_source/jump_value.rb +59 -0
  96. data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
  97. data/lib/saboteur/mutation_source/operator_examples.rb +6 -0
  98. data/lib/saboteur/mutation_source/operator_registry.rb +6 -0
  99. data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
  100. data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
  101. data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
  102. data/lib/saboteur/mutation_source/when_removal.rb +51 -0
  103. data/lib/saboteur/preview.rb +13 -0
  104. data/lib/saboteur/refinement_activations.rb +22 -10
  105. data/lib/saboteur/reporter/agent.rb +2 -2
  106. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  107. data/lib/saboteur/reporter/agent_summary.rb +16 -10
  108. data/lib/saboteur/reporter/review_comments.rb +151 -0
  109. data/lib/saboteur/reporter/safe_out.rb +32 -0
  110. data/lib/saboteur/reporter/stryker.rb +8 -6
  111. data/lib/saboteur/reporter/survivor_classification.rb +41 -3
  112. data/lib/saboteur/reporter/survivor_hints.rb +5 -0
  113. data/lib/saboteur/reporter/terminal.rb +45 -5
  114. data/lib/saboteur/result.rb +14 -4
  115. data/lib/saboteur/selection/coverage_map.rb +150 -9
  116. data/lib/saboteur/selection/external_test_map.rb +4 -0
  117. data/lib/saboteur/selection/git_diff.rb +24 -2
  118. data/lib/saboteur/selection/layout_only_lines.rb +45 -1
  119. data/lib/saboteur/selection/recorder.rb +52 -22
  120. data/lib/saboteur/selection/trace_recorder.rb +83 -0
  121. data/lib/saboteur/selection/untracked_changes.rb +1 -2
  122. data/lib/saboteur/subject.rb +16 -0
  123. data/lib/saboteur/subject_finder.rb +91 -6
  124. data/lib/saboteur/text.rb +31 -0
  125. data/lib/saboteur/version.rb +1 -1
  126. data/lib/saboteur/working_tree.rb +7 -2
  127. data/lib/saboteur.rb +9 -0
  128. metadata +48 -16
@@ -0,0 +1,22 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Splits a suite's spec files into disjoint parts for parallel coverage
7
+ # builds (#741). The sorted list is dealt round-robin, the rule
8
+ # script/rspec-shard uses without weights, so every file lands in exactly
9
+ # one part and the split depends only on the file list, never on load or
10
+ # discovery order. A part that would be empty is not returned.
11
+ class SuitePartition
12
+ def self.parts(files, total)
13
+ raise ArgumentError, "parts must be at least 1, got #{total}" if total < 1
14
+
15
+ sorted = files.uniq.sort
16
+ dealt = Array.new([total, sorted.size].min) { [] }
17
+ sorted.each_with_index { |file, position| dealt[position % dealt.size] << file }
18
+ dealt
19
+ end
20
+ end
21
+ end
22
+ end
@@ -16,6 +16,38 @@ module Saboteur
16
16
  # subject need a guessed spec; without --since, every included file with
17
17
  # a mutation subject needs one. A file that needs a spec and has none
18
18
  # stops the run before boot.
19
+ # A project with no *_spec.rb anywhere under its root has no RSpec suite
20
+ # at all: a per-file stop would print one line per
21
+ # source file and bury the real problem (#606, sibling of the
22
+ # missing-rspec-core refusal #542). The stop is one line naming it, with
23
+ # a Minitest pointer when test/ exists; --verbose keeps the per-file list.
24
+ module NoSuiteStop
25
+ MINITEST = "no RSpec suite found - saboteur needs RSpec; " \
26
+ "this project appears to use Minitest (test/), see docs/minitest.md"
27
+ PLAIN = "no RSpec suite found - saboteur needs RSpec; no spec file exists to run, see docs/minitest.md"
28
+
29
+ module_function
30
+
31
+ # nil when the run should not claim a missing suite: a --subject run
32
+ # already names its expected spec, nothing is unmatched, or a suite
33
+ # exists. The suite can live outside spec/ - rspec_args can point
34
+ # RSpec at another default path - so look under the whole root;
35
+ # claiming "no spec file exists" for such a project is false and
36
+ # drops the --test/--all remedy (#606). :minitest or :plain otherwise.
37
+ def layout(root, unmatched, subject_run)
38
+ return if subject_run || unmatched.empty?
39
+
40
+ return unless Dir.glob(File.join(root, "**", "*_spec.rb")).empty?
41
+
42
+ File.directory?(File.join(root, "test")) ? :minitest : :plain
43
+ end
44
+
45
+ def message(layout, pairs:, verbose: false)
46
+ line = layout == :minitest ? MINITEST : PLAIN
47
+ verbose ? "#{line} Files without a guessed spec: #{pairs.join(', ')}." : line
48
+ end
49
+ end
50
+
19
51
  class TestGuess
20
52
  # hints: unmatched file -> its conventional spec path (which does
21
53
  # not exist; that is why the file is unmatched), for the stop-before-boot
@@ -25,8 +57,10 @@ module Saboteur
25
57
  # in specs; skipped_specs: spec files the diff touches that cannot run,
26
58
  # each with the reason.
27
59
  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)
60
+ :skipped_specs, :out_of_scope, :excluded, :near, :no_suite, :without_map_entry,
61
+ :referenced, :crowded) do
62
+ def initialize(changed_specs: [], skipped_specs: {}, out_of_scope: [], excluded: [], near: {},
63
+ no_suite: nil, without_map_entry: [], referenced: {}, crowded: {}, **fields)
30
64
  super
31
65
  end
32
66
 
@@ -35,12 +69,22 @@ module Saboteur
35
69
  # command, which could carry a hostile ref or a wrong source scope.
36
70
  # When the diff deleted the expected spec or added others, it says
37
71
  # so: a renamed spec is the usual cause.
38
- def unmatched_message
72
+ def unmatched_message(verbose: false)
73
+ if no_suite
74
+ return NoSuiteStop.message(no_suite, pairs: unmatched.map { |f| pair_description(f) }, verbose: verbose)
75
+ end
76
+
39
77
  pairs = unmatched.map { |file| pair_description(file) }
40
- changed = changed_specs.any? ? " Specs the diff adds or changes: #{changed_specs.join(', ')}." : ""
78
+ changed = changed_specs.any? ? " Specs the diff adds or changes: #{names(changed_specs)}." : ""
79
+ # The map is the documented escape hatch for layouts the
80
+ # convention cannot guess - named only for files no entry covers;
81
+ # a file whose own entry broke heard that in the warning (#640).
82
+ mapped = without_map_entry.any? &&
83
+ " If the layout defeats the naming convention, map #{names(without_map_entry)} by hand in " \
84
+ "config/saboteur_custom_map.rb: no entry covers it."
41
85
  "no spec found for #{pairs.join(', ')}, so nothing ran.#{changed} Re-run the same command with " \
42
86
  "one --test per existing spec path, or append --all to use the whole suite without " \
43
- "changing the source scope."
87
+ "changing the source scope.#{mapped}"
44
88
  end
45
89
 
46
90
  private
@@ -48,22 +92,31 @@ module Saboteur
48
92
  def pair_description(file)
49
93
  expected = hints.fetch(file)
50
94
  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})"
95
+ found = near.fetch(file, []).any? ? "; found: #{names(near.fetch(file))}" : ""
96
+ crowd = (count = crowded[file]) ? "; #{ReferenceSpecs.crowd_note(count)}" : ""
97
+ "#{Text.display_safe(file)} (expected #{Text.display_safe(expected)}#{gone}#{found}#{crowd})"
53
98
  end
54
99
 
100
+ # Repo-controlled names reach CI logs, so each is escaped before it
101
+ # joins a message (#834): a file name may hold a newline.
102
+ def names(list) = list.map { |name| Text.display_safe(name) }.join(", ")
103
+
55
104
  public
56
105
 
106
+ # The files that had no spec by name and got the specs that read
107
+ # their constants instead (#725 replaces the guess once a map exists).
108
+ def referenced_message = ReferenceSpecs.note(referenced)
109
+
57
110
  # The spec files the diff touched that do not run, with the reasons.
58
111
  def skipped_message
59
- named = skipped_specs.map { |path, reason| "#{path} (#{reason})" }
112
+ named = skipped_specs.map { |path, reason| "#{Text.display_safe(path)} (#{reason})" }
60
113
  "inferred specs: not running spec files changed in the diff: #{named.join(', ')}"
61
114
  end
62
115
 
63
116
  # Why the subjectless files needed no spec.
64
117
  def subjectless_message(since:)
65
118
  detail = since ? "no changed mutation subjects" : "no mutation subjects"
66
- "inferred specs: no spec is required for #{subjectless.join(', ')}: they have #{detail}"
119
+ "inferred specs: no spec is required for #{names(subjectless)}: they have #{detail}"
67
120
  end
68
121
 
69
122
  # The per-file warning when the run proceeds with the mapped
@@ -80,7 +133,7 @@ module Saboteur
80
133
  # scope hid. Append-only recovery, like unmatched_message: extend include, or
81
134
  # name the specs with --test.
82
135
  def out_of_scope_message
83
- named = out_of_scope.first(10).join(", ")
136
+ named = names(out_of_scope.first(10))
84
137
  more = out_of_scope.size - 10
85
138
  named += " and #{more} more" if more.positive?
86
139
  "no changed source file lies inside the include scope, so nothing ran. " \
@@ -103,7 +156,7 @@ module Saboteur
103
156
 
104
157
  # Why the custom-map-excluded files needed no spec (#369).
105
158
  def excluded_message
106
- "inferred specs: no spec is required for #{excluded.join(', ')}: " \
159
+ "inferred specs: no spec is required for #{names(excluded)}: " \
107
160
  "intentionally untested in config/saboteur_custom_map.rb"
108
161
  end
109
162
 
@@ -148,7 +201,9 @@ module Saboteur
148
201
  def call_for(files, patterns = [])
149
202
  files = effective_subject_files(files, patterns) if @config["since"]
150
203
  guesses = files.to_h { |file| [file, guess_for(file)] }
151
- result(guesses, [], files.size)
204
+ # A --subject run already knows its suite: the stop names the one
205
+ # expected spec, never the no-suite line (#606 is the full-run flood).
206
+ result(guesses, [], files.size, subject_run: true)
152
207
  end
153
208
 
154
209
  # specs: existing spec files (relative paths) guessed for the files in
@@ -209,24 +264,47 @@ module Saboteur
209
264
  end
210
265
  end
211
266
 
267
+ # The specs the diff itself adds or changes, when a --since run has
268
+ # any guesses to join them with; empty otherwise.
269
+ def diff_specs(inferred, guesses)
270
+ @config["since"] && guesses.any? ? specs_in_diff(inferred) : { run: [], skipped: {} }
271
+ end
272
+
273
+ # Unmatched files no map entry even tries to cover: the stop points
274
+ # those at the map (#640); a file whose own entry broke already heard
275
+ # about it in the guess-time warning.
276
+ def without_map_entry(unmatched)
277
+ unmatched.select { |file| @custom_map.specs_for(file).nil? }
278
+ end
279
+
212
280
  # Changed specs join only a run that mutates something: with no file
213
281
  # in scope there is nothing for them to kill.
214
- def result(guesses, subjectless, changed, out_of_scope: [])
282
+ def result(guesses, subjectless, changed, out_of_scope: [], subject_run: false)
215
283
  unmatched = guesses.select { |_, specs| specs.nil? }.keys
216
284
  excluded = guesses.select { |_, specs| specs == :excluded }.keys.sort
217
285
  inferred = guesses.values.grep(Array).flatten
218
- diff = @config["since"] && guesses.any? ? specs_in_diff(inferred) : { run: [], skipped: {} }
286
+ diff = diff_specs(inferred, guesses)
219
287
  unmatched -= covered_by_changed_specs(unmatched, diff[:run])
220
- Result.new(specs: (inferred + diff[:run]).uniq.sort,
288
+ unmatched, referenced = split_referenced(unmatched)
289
+ Result.new(specs: (inferred + diff[:run] + referenced.values.flatten).uniq.sort,
290
+ referenced: referenced,
221
291
  unmatched: unmatched,
292
+ without_map_entry: without_map_entry(unmatched),
222
293
  subjectless: subjectless,
223
294
  changed: changed,
224
- hints: unmatched.to_h { |file| [file, expected_spec(file, diff[:skipped])] },
225
- near: unmatched.to_h { |file| [file, nearby_specs(file)] },
295
+ **stop_details(unmatched, diff[:skipped]),
226
296
  changed_specs: diff[:run],
227
297
  skipped_specs: diff[:skipped],
228
298
  out_of_scope: out_of_scope,
229
- excluded: excluded)
299
+ excluded: excluded,
300
+ no_suite: NoSuiteStop.layout(@root, unmatched, subject_run))
301
+ end
302
+
303
+ # What the stop names for each unmatched file: the spec it expected and
304
+ # the same-named specs elsewhere.
305
+ def stop_details(unmatched, skipped)
306
+ { hints: unmatched.to_h { |file| [file, expected_spec(file, skipped)] },
307
+ near: unmatched.to_h { |file| [file, nearby_specs(file)] }, crowded: @crowded }
230
308
  end
231
309
 
232
310
  # Changed Ruby files the include scope hides (#483), relative to the
@@ -274,6 +352,26 @@ module Saboteur
274
352
  end
275
353
  end
276
354
 
355
+ # Cold start, no map: the specs that read a constant an unmatched file
356
+ # defines (#538 matching). Only files that would stop the run today; a
357
+ # same-basename spec elsewhere means the user has a spec in mind and
358
+ # the choice is ambiguous: that stop stays, it names the candidates (#658).
359
+ def split_referenced(unmatched)
360
+ referenced = reference_specs(unmatched)
361
+ [unmatched - referenced.keys, referenced]
362
+ end
363
+
364
+ def reference_specs(unmatched)
365
+ @crowded = {}
366
+ unmatched = unmatched.select { |file| nearby_specs(file).empty? }
367
+ return {} if unmatched.empty? || suite_files.nil?
368
+
369
+ specs = suite_files.select { |path| spec?(path) }
370
+ found = ReferenceSpecs.new(@root, method(:defined_constants)).call(unmatched, specs)
371
+ @crowded = found.crowded
372
+ found.chosen
373
+ end
374
+
277
375
  # The constants a file defines: owners of its def subjects
278
376
  # ("JWT::Claims::DecodeVerifier" from "...#call") and its constant
279
377
  # literal subjects. Shaped for ConstantReads, which matches a bare
@@ -303,13 +401,17 @@ module Saboteur
303
401
  # --test without hunting (#370). Bounded; computed only for
304
402
  # unmatched files, which are about to stop or warn.
305
403
  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}" }
404
+ basename = escape_glob(File.basename(file, ".rb"))
309
405
  Dir.glob("spec/**/#{basename}_spec.rb", base: @root)
310
406
  .select { |path| File.file?(File.join(@root, path)) }.sort.first(3)
311
407
  end
312
408
 
409
+ # A path segment is data, not a pattern: [, ], {, }, ?, * and \ are
410
+ # glob metacharacters and must be escaped to match literally.
411
+ def escape_glob(segment)
412
+ segment.gsub(/[\[\]{}?*\\]/) { |char| "\\#{char}" }
413
+ end
414
+
313
415
  # lstat, not stat: a symlink can point anywhere, including a spec of
314
416
  # another application or a fixture the suite excludes.
315
417
  def unrunnable_reason(path)
@@ -414,18 +516,24 @@ module Saboteur
414
516
  def guess_for(file)
415
517
  mapped = @custom_map.specs_for(file)
416
518
  return :excluded if mapped == :excluded
417
- return expand_mapped(file, mapped) if mapped
519
+
520
+ expanded = mapped && expand_mapped(file, mapped)
521
+ return expanded if expanded
418
522
 
419
523
  conventional = candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
420
- conventional && [conventional]
524
+ return [conventional] if conventional
525
+
526
+ unique = unique_suffix_spec(file)
527
+ unique && [unique]
421
528
  end
422
529
 
423
530
  # A mapped spec can be a glob (expanded against the root) or a regexp
424
531
  # backreference filled at match time, and neither is validated to
425
532
  # exist when the map loads. Only specs that exist run; the literal
426
533
  # 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).
534
+ # vanish the entry is stale or mistyped: warn and fall through to the
535
+ # naming convention (and, failing that, to the usual stop guidance),
536
+ # instead of dying with a ConfigError and hiding the way out (#640).
429
537
  def expand_mapped(file, specs)
430
538
  expanded = specs.flat_map do |spec|
431
539
  if spec.match?(/[*?\[{]/)
@@ -437,7 +545,10 @@ module Saboteur
437
545
  expanded = expanded.select { |path| File.file?(File.join(@root, path)) }
438
546
  return expanded unless expanded.empty?
439
547
 
440
- raise ConfigError, "custom map entry for #{file} names spec(s) #{specs.join(', ')}, which match no file"
548
+ shown = specs.first(5).map { |spec| Text.display_safe(spec) }.join(", ")
549
+ shown += ", and #{specs.size - 5} more" if specs.size > 5
550
+ @diagnostics&.warn("fix the custom map entry for #{Text.display_safe(file)}: spec(s) #{shown} match no file")
551
+ nil
441
552
  end
442
553
 
443
554
  # Candidate specs for a source file, most specific first: the nearest
@@ -464,14 +575,31 @@ module Saboteur
464
575
 
465
576
  # Layouts that mirror less of the source path, after every mirrored
466
577
  # 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.
578
+ # it, #393), directories dropped between the gem namespace and the
579
+ # file (rubocop-ast tests lib/rubocop/ast/node/block_node.rb in
580
+ # spec/rubocop/ast/block_node_spec.rb, #586), the basename at the
581
+ # spec root (pundit), and both together (aasm, #370). Weaker
582
+ # guesses come last: the first candidate that exists wins. A file
583
+ # outside the source roots keeps its literal guess only: dropping
584
+ # directories from a path the roots never anchored would guess any
585
+ # same-named spec.
472
586
  def layout_variants(names)
473
587
  flat = names.map { |name| name.split("/").last }.uniq
474
- (names + names.map { |name| "unit/#{name}" } + flat + flat.map { |name| "unit/#{name}" }).uniq
588
+ (names + names.map { |name| "unit/#{name}" } + flattened_below_namespace(names) + flat +
589
+ flat.map { |name| "unit/#{name}" }).uniq
590
+ end
591
+
592
+ # Every way to drop mid directories from a name, fewest dropped
593
+ # first: each guess keeps at least the leading namespace segment
594
+ # and the basename, so it stays anchored on the gem namespace and
595
+ # more specific than the bare basename flatten (#586).
596
+ def flattened_below_namespace(names)
597
+ names.flat_map do |name|
598
+ segments = name.split("/")
599
+ (segments.size - 2).downto(1).map do |keep|
600
+ (segments.first(keep) + [segments.last]).join("/")
601
+ end
602
+ end.uniq
475
603
  end
476
604
 
477
605
  # Rails tests controllers as request specs: spec/requests/users_spec.rb
@@ -483,6 +611,32 @@ module Saboteur
483
611
  ["requests/#{path}", "requests/#{path.delete_suffix('_controller')}"].uniq
484
612
  end
485
613
 
614
+ # The last-resort guess when no named candidate exists (#600): a
615
+ # spec tree can strip directories the convention keeps - aasm tests
616
+ # lib/aasm/core/invokers/proc_invoker.rb in
617
+ # spec/unit/invokers/proc_invoker_spec.rb, dropping the aasm and
618
+ # core segments. The one anchor such a layout keeps is the file's
619
+ # parent directory and basename, so a spec under the spec root
620
+ # ending in that suffix is the guess - but only a unique one: a
621
+ # second match makes the layout ambiguous, and guessing would run
622
+ # the wrong specs, a false verdict worse than the stop, whose
623
+ # near-miss hint names the candidates. Only files under a source
624
+ # root with a real parent directory qualify: a bare basename can
625
+ # collide anywhere, so it stays a near-miss hint.
626
+ def unique_suffix_spec(file)
627
+ pack = pack_of(file)
628
+ stem = file.delete_prefix("#{pack}/").delete_suffix(".rb")
629
+ root_segment, _, name = stem.partition("/")
630
+ return nil unless SOURCE_ROOTS.include?(root_segment) && name.include?("/")
631
+
632
+ suffix = name.split("/").last(2).map { |segment| escape_glob(segment) }.join("/")
633
+ spec_root = pack ? "#{pack}/spec" : "spec"
634
+ matches = Dir.glob(File.join(@root, spec_root, "**", "#{suffix}_spec.rb"))
635
+ .select { |path| File.file?(path) }
636
+ .map { |path| path.delete_prefix("#{@root}/") }.sort
637
+ matches.one? ? matches.first : nil
638
+ end
639
+
486
640
  # The nearest ancestor directory holding a package.yml (a packwerk
487
641
  # pack), relative to the root. The root itself is not a pack here:
488
642
  # Pathname#ascend stops above "." for these relative paths.
@@ -45,8 +45,16 @@ module Saboteur
45
45
  files.flat_map do |file|
46
46
  next [] unless file.end_with?(".rb") && File.file?(file)
47
47
 
48
- current_ids = unique_by_id(File.read(file), file).keys
49
- base_subjects(file).values.reject { |subject| current_ids.include?(subject.id) }
48
+ # The diff reaches files outside the mutation scope (fixtures,
49
+ # scripts): an in-scope file nested too deeply already refused
50
+ # the run at subject finding, so a DeepSourceError here can only
51
+ # come from a file the run never mutates. Skip it (#719, #730).
52
+ begin
53
+ current_ids = unique_by_id(File.read(file), file).keys
54
+ base_subjects(file).values.reject { |subject| current_ids.include?(subject.id) }
55
+ rescue DeepSourceError
56
+ []
57
+ end
50
58
  end
51
59
  end
52
60
 
@@ -16,6 +16,7 @@ require_relative "engine/thread_warnings"
16
16
 
17
17
  require_relative "engine/pipelines"
18
18
  require_relative "engine/shard"
19
+ require_relative "engine/suite_partition"
19
20
 
20
21
  module Saboteur
21
22
  # A single-use instance runs one pipeline: boot the target, select subjects, fetch the
@@ -44,7 +45,7 @@ module Saboteur
44
45
  @root = File.realpath(config.root)
45
46
  @skipped_subjects = {}
46
47
  @replay = Replay.new(digest: digest) if digest
47
- if capture_evidence && (digest || config["since"] || config["incremental"] || config["shard"])
48
+ if capture_evidence && (digest || config["since"] || config["incremental"] || config["resume"] || config["shard"])
48
49
  raise ConfigError,
49
50
  "baseline evidence requires fresh whole-scope execution: no since, replay, shard or incremental"
50
51
  end
@@ -76,9 +77,19 @@ module Saboteur
76
77
  def preflight!
77
78
  Boot.refuse_recursion!
78
79
  refuse_fail_fast_evidence
80
+ verify_resume_directory
79
81
  end
80
82
 
81
- def warn_custom_map = @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
83
+ # An unusable --resume directory fails before the expensive phases (#471).
84
+ def verify_resume_directory
85
+ return if @replay || !config["resume"]
86
+
87
+ ResumeManifest.new(directory: config.path("resume"), identity: Digest::SHA256.hexdigest(verdict_inputs)).verify!
88
+ rescue ResumeManifest::Mismatch => e
89
+ raise ConfigError, e.message
90
+ end
91
+
92
+ def warn_custom_map = @diagnostics.add_warnings(custom_map_warnings(load_custom_map))
82
93
 
83
94
  # Stale entries and duplicate literals never fail a run; they warn so the
84
95
  # map does not rot silently. doctor fails on stale keys.
@@ -136,7 +147,7 @@ module Saboteur
136
147
  return if guess.changed.zero?
137
148
 
138
149
  inform_skipped_specs(guess)
139
- raise ConfigError, guess.unmatched_message if guess.unmatched.any?
150
+ raise ConfigError, guess.unmatched_message(verbose: @diagnostics.verbose) if guess.unmatched.any?
140
151
 
141
152
  @diagnostics.inform(guess.excluded_message) if guess.excluded.any?
142
153
  # Every file in scope intentionally untested: mutate nothing. An
@@ -152,13 +163,15 @@ module Saboteur
152
163
  def build_report(results, subjects, scope, started)
153
164
  Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
154
165
  invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
166
+ live_announced: @diagnostics.live_announced,
155
167
  review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
156
168
  diff_scope: scope, generation_events: @diagnostics.generation_events,
157
169
  omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
158
170
  hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
159
171
  matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
160
172
  operator_policy: config.operator_policy, discovery_failures: @diagnostics.discovery_failures,
161
- shard: @shard, memory: @memory_report)
173
+ test_command: config["test_command"],
174
+ shard: @shard, memory: @memory_report, referenced: @guessed_referenced || {})
162
175
  end
163
176
 
164
177
  def complete_evidence(results, subjects)
@@ -261,14 +274,16 @@ module Saboteur
261
274
  def coverage_store(integration, load_lines, load_methods = {})
262
275
  CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:, load_methods:,
263
276
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
264
- timeout_cap: method(:budget_capped_timeout))
277
+ timeout_cap: method(:budget_capped_timeout),
278
+ parts: config["coverage_parts"])
265
279
  end
266
280
 
267
281
  def check_baseline(integration, full_passes)
268
282
  guard = BaselineGuard.new(config: config, integration: integration, hooks: hooks,
269
283
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
270
284
  full_passes: full_passes, timeout_cap: method(:budget_capped_timeout),
271
- between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil)
285
+ between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil,
286
+ run_started: @run_started)
272
287
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
273
288
  guard.call
274
289
  @baseline_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
@@ -308,15 +323,18 @@ module Saboteur
308
323
 
309
324
  def result_cache(map = nil, files = [])
310
325
  return nil if @replay
311
- return nil unless config["incremental"]
326
+ return nil unless config["incremental"] || config["resume"]
312
327
 
313
- inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }
314
- .merge("operator_policy" => config.operator_policy))
315
- ResultCache.new(path: File.join(config.path("cache_dir"), "results.json"), root: @root, inputs: inputs,
328
+ directory = config.path("resume") || config.path("cache_dir")
329
+ ResultCache.new(path: File.join(directory, "results.json"), root: @root, inputs: verdict_inputs,
316
330
  suite_digest: SuiteFiles.new(config).digest,
317
331
  coverage: map, fallback_files: source_files(files))
318
332
  end
319
333
 
334
+ def verdict_inputs
335
+ JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }.merge("operator_policy" => config.operator_policy))
336
+ end
337
+
320
338
  # A cleanup failure must never mask the run's own outcome - not even
321
339
  # one cleanup_workers did not anticipate.
322
340
  def safely_cleanup_workers(rails)
@@ -365,7 +383,8 @@ module Saboteur
365
383
 
366
384
  candidates = Candidates.build(config, @diagnostics, hazard_guard: hazard_guard)
367
385
  selection = CandidateSelection.new(candidates: candidates, map: map,
368
- limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
386
+ limit: config["max_mutants_per_method"], diagnostics: @diagnostics,
387
+ root: @root)
369
388
  lists = subjects.map { |subject| selection.for(subject, scope: scope) }
370
389
  @shard = Engine::Shard.parse(config["shard"]) if config["shard"]
371
390
  lists = @shard.select(subjects, lists) if @shard
@@ -427,6 +446,9 @@ module Saboteur
427
446
 
428
447
  def suspend_coverage(files)
429
448
  (@coverage_guard = CoverageGuard.new(@diagnostics)).suspend
449
+ # Coverage stops counting here, so the call tracer stops with it
450
+ # instead of crediting the calls the engine itself makes afterwards.
451
+ MethodCalls.booted&.pause
430
452
  LoadCoverage.capture(files)
431
453
  end
432
454
 
@@ -440,8 +462,18 @@ require_relative "engine/boot"
440
462
  require_relative "engine/subject_selection"
441
463
  require_relative "engine/unchanged_syntax"
442
464
  require_relative "engine/suite_files"
465
+ require_relative "engine/reference_specs"
443
466
  require_relative "engine/test_guess"
444
467
  require_relative "engine/sandbox_policy"
468
+ require_relative "engine/coverage_inputs_snapshot"
469
+ require_relative "engine/partial_invalidation"
470
+ require_relative "engine/partial_merge"
471
+ require_relative "engine/shadow_comparison"
472
+ require_relative "engine/shadow_run"
473
+ require_relative "engine/coverage_shadow"
474
+ require_relative "engine/coverage_partial"
475
+ require_relative "engine/coverage_parts"
476
+ require_relative "engine/coverage_miss_note"
445
477
  require_relative "engine/coverage_store"
446
478
  require_relative "engine/baseline_guard"
447
479
  require_relative "engine/unscoped_suite_warning"
@@ -450,6 +482,7 @@ require_relative "engine/candidates"
450
482
  require_relative "engine/candidate_selection"
451
483
  require_relative "engine/diff_scope"
452
484
  require_relative "engine/result_cache"
485
+ require_relative "engine/resume_manifest"
453
486
  require_relative "engine/defined_order_prefix"
454
487
  require_relative "engine/verdict_check"
455
488
  require_relative "engine/scheduler"
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # Environment hints for runs started the wrong way (#598, #609). Detected
6
+ # from files, never from error text; advisory only - nothing refuses.
7
+ module EnvironmentHints
8
+ module_function
9
+
10
+ # One line per detected problem, each a full sentence for the channel it
11
+ # lands in (boot warning, doctor line, or load-failure appendix).
12
+ def all(root, bundler_loaded: !defined?(Bundler).nil?, env: ENV)
13
+ [bundler(root, bundler_loaded: bundler_loaded), appraisal(root, env: env)].compact
14
+ rescue StandardError
15
+ # An advisory hint must never stop a run (Pod 1 security review).
16
+ []
17
+ end
18
+
19
+ # #598: a lockfile but no Bundler means gems resolve to whatever is
20
+ # installed, not the lockfile's versions.
21
+ def bundler(root, bundler_loaded: !defined?(Bundler).nil?)
22
+ return nil unless File.file?(File.join(root, "Gemfile.lock"))
23
+ return nil if bundler_loaded
24
+
25
+ "Gemfile.lock found but Bundler is not loaded: gems resolve to whatever is installed, not the " \
26
+ "lockfile's versions - run `bundle exec saboteur run`"
27
+ end
28
+
29
+ # #609: an Appraisal project run without BUNDLE_GEMFILE pointing into
30
+ # gemfiles/ loads the wrong (or no) dependency set.
31
+ def appraisal(root, env: ENV)
32
+ appraisals = File.file?(File.join(root, "Appraisals"))
33
+ gemfiles = Dir.glob(File.join(root, "gemfiles", "*.gemfile"))
34
+ return nil unless appraisals || gemfiles.any?
35
+
36
+ setting = env["BUNDLE_GEMFILE"].to_s
37
+ under = !setting.empty? &&
38
+ File.expand_path(setting, root).start_with?("#{File.join(File.expand_path(root), 'gemfiles')}/")
39
+ return nil if under
40
+
41
+ found = (appraisals ? ["Appraisals"] : []) +
42
+ gemfiles.map { |f| sanitize(f.sub("#{File.expand_path(root)}/", "")) }
43
+ where = setting.empty? ? "is not set" : "points outside gemfiles/ (#{sanitize(setting)})"
44
+ "Appraisal gemfiles found (#{Text.name_list(found)}) but BUNDLE_GEMFILE #{where}: run " \
45
+ "`BUNDLE_GEMFILE=gemfiles/<name>.gemfile bundle exec saboteur run` or " \
46
+ "`bundle exec appraisal <name> saboteur run`"
47
+ end
48
+
49
+ # A cloned repo can name gemfiles with terminal control characters (Pod 1
50
+ # security review): escape them before they reach the terminal.
51
+ def sanitize(text)
52
+ text.scrub("?").gsub(/[\x00-\x1f\x7f\u0080-\u009f]/) { |c| format("\\x%02x", c.ord) }
53
+ end
54
+ end
55
+ end