saboteur 0.8.1 → 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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/README.md +10 -0
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/attribute_finder.rb +3 -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/preview_command.rb +26 -0
  20. data/lib/saboteur/cli/run_budget_options.rb +3 -0
  21. data/lib/saboteur/cli/run_command.rb +33 -0
  22. data/lib/saboteur/cli/run_output.rb +38 -0
  23. data/lib/saboteur/cli/run_reporting.rb +6 -5
  24. data/lib/saboteur/cli/run_validation.rb +54 -0
  25. data/lib/saboteur/cli.rb +29 -307
  26. data/lib/saboteur/config.rb +72 -6
  27. data/lib/saboteur/constant_subject_finder.rb +3 -0
  28. data/lib/saboteur/custom_map.rb +20 -12
  29. data/lib/saboteur/deep_source.rb +49 -0
  30. data/lib/saboteur/dirty_tree_warning.rb +1 -1
  31. data/lib/saboteur/doctor.rb +6 -0
  32. data/lib/saboteur/engine/baseline_guard.rb +50 -13
  33. data/lib/saboteur/engine/baseline_parts.rb +51 -0
  34. data/lib/saboteur/engine/boot.rb +4 -0
  35. data/lib/saboteur/engine/candidate_selection.rb +15 -5
  36. data/lib/saboteur/engine/candidates.rb +4 -1
  37. data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
  38. data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
  39. data/lib/saboteur/engine/coverage_partial.rb +48 -0
  40. data/lib/saboteur/engine/coverage_parts.rb +50 -0
  41. data/lib/saboteur/engine/coverage_shadow.rb +92 -0
  42. data/lib/saboteur/engine/coverage_store.rb +275 -22
  43. data/lib/saboteur/engine/diagnostics.rb +23 -6
  44. data/lib/saboteur/engine/distribution.rb +2 -2
  45. data/lib/saboteur/engine/gated_run.rb +1 -0
  46. data/lib/saboteur/engine/load_coverage.rb +54 -8
  47. data/lib/saboteur/engine/partial_invalidation.rb +115 -0
  48. data/lib/saboteur/engine/partial_merge.rb +40 -0
  49. data/lib/saboteur/engine/pipelines.rb +7 -7
  50. data/lib/saboteur/engine/reference_specs.rb +71 -0
  51. data/lib/saboteur/engine/result_cache.rb +68 -5
  52. data/lib/saboteur/engine/resume_manifest.rb +110 -0
  53. data/lib/saboteur/engine/run_budget.rb +5 -5
  54. data/lib/saboteur/engine/run_fingerprint.rb +2 -1
  55. data/lib/saboteur/engine/sandbox_policy.rb +5 -2
  56. data/lib/saboteur/engine/scheduler.rb +1 -1
  57. data/lib/saboteur/engine/selection_announcement.rb +1 -0
  58. data/lib/saboteur/engine/shadow_comparison.rb +121 -0
  59. data/lib/saboteur/engine/shadow_run.rb +38 -0
  60. data/lib/saboteur/engine/subject_selection.rb +33 -37
  61. data/lib/saboteur/engine/suite_partition.rb +22 -0
  62. data/lib/saboteur/engine/test_guess.rb +82 -18
  63. data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
  64. data/lib/saboteur/engine.rb +41 -9
  65. data/lib/saboteur/environment_hints.rb +55 -0
  66. data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
  67. data/lib/saboteur/execution/external_runner.rb +105 -1
  68. data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
  69. data/lib/saboteur/execution/sandbox.rb +30 -17
  70. data/lib/saboteur/execution/supervisor.rb +40 -7
  71. data/lib/saboteur/execution/supervisor_batch.rb +105 -0
  72. data/lib/saboteur/execution/worker_pool.rb +48 -4
  73. data/lib/saboteur/frameworks/rails.rb +2 -5
  74. data/lib/saboteur/gates.rb +121 -28
  75. data/lib/saboteur/install.rb +6 -1
  76. data/lib/saboteur/integration/base.rb +19 -2
  77. data/lib/saboteur/integration/rspec.rb +46 -7
  78. data/lib/saboteur/line_hits.rb +81 -0
  79. data/lib/saboteur/method_calls.rb +91 -0
  80. data/lib/saboteur/mutation_source/builtin.rb +32 -22
  81. data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
  82. data/lib/saboteur/mutation_source/jump_value.rb +59 -0
  83. data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
  84. data/lib/saboteur/mutation_source/operator_examples.rb +5 -0
  85. data/lib/saboteur/mutation_source/operator_registry.rb +5 -0
  86. data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
  87. data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
  88. data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
  89. data/lib/saboteur/mutation_source/when_removal.rb +51 -0
  90. data/lib/saboteur/preview.rb +9 -1
  91. data/lib/saboteur/refinement_activations.rb +22 -10
  92. data/lib/saboteur/reporter/agent.rb +2 -2
  93. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  94. data/lib/saboteur/reporter/agent_summary.rb +16 -10
  95. data/lib/saboteur/reporter/review_comments.rb +151 -0
  96. data/lib/saboteur/reporter/safe_out.rb +32 -0
  97. data/lib/saboteur/reporter/stryker.rb +8 -6
  98. data/lib/saboteur/reporter/survivor_classification.rb +41 -3
  99. data/lib/saboteur/reporter/survivor_hints.rb +5 -0
  100. data/lib/saboteur/reporter/terminal.rb +29 -4
  101. data/lib/saboteur/result.rb +14 -4
  102. data/lib/saboteur/selection/coverage_map.rb +136 -10
  103. data/lib/saboteur/selection/external_test_map.rb +4 -0
  104. data/lib/saboteur/selection/git_diff.rb +24 -2
  105. data/lib/saboteur/selection/layout_only_lines.rb +45 -1
  106. data/lib/saboteur/selection/recorder.rb +52 -22
  107. data/lib/saboteur/selection/trace_recorder.rb +83 -0
  108. data/lib/saboteur/selection/untracked_changes.rb +1 -2
  109. data/lib/saboteur/subject.rb +16 -0
  110. data/lib/saboteur/subject_finder.rb +91 -6
  111. data/lib/saboteur/text.rb +31 -0
  112. data/lib/saboteur/version.rb +1 -1
  113. data/lib/saboteur/working_tree.rb +7 -2
  114. data/lib/saboteur.rb +8 -0
  115. metadata +44 -2
@@ -134,7 +134,8 @@ module Saboteur
134
134
  else
135
135
  @diagnostics.record_discovery_failure(file, reason: "invalid_file_syntax")
136
136
  parsed.errors.first(1).each do |message|
137
- @diagnostics.warn("#{file.delete_prefix("#{@root}/")} does not parse: #{message}")
137
+ @diagnostics.warn("fix the syntax in #{Text.display_safe(file.delete_prefix("#{@root}/"))}: " \
138
+ "does not parse (#{Text.display_safe(message)})")
138
139
  end
139
140
  end
140
141
  end
@@ -179,7 +180,7 @@ module Saboteur
179
180
  macro = attribute_macro_for(pattern)
180
181
  return "--subject #{pattern} is defined by #{macro} and has no body to mutate" if macro
181
182
 
182
- "--subject #{pattern} matches no subject (patterns are names like " \
183
+ "fix the --subject pattern \"#{pattern}\": it matches no subject (patterns are names like " \
183
184
  "Klass, Klass#meth or Klass.meth, not globs)"
184
185
  end
185
186
 
@@ -266,12 +267,13 @@ module Saboteur
266
267
  return subjects if reasons.empty?
267
268
 
268
269
  constant_warned_files << canonical
269
- path = file.delete_prefix("#{@root}/")
270
+ path = Text.display_safe(file.delete_prefix("#{@root}/"))
270
271
  listed = reasons.first(2).join(", ")
271
- @diagnostics.warn("--since: constant subject(s) in #{path} are not mutated: the file runs code at " \
272
- "load (#{listed}) that every constant mutant would run again, and saboteur " \
273
- "cannot vet what re-running it does. List the constant in c.whole_file to " \
274
- "replay the file anyway, or move the literal into its own file. " \
272
+ listed += ", and #{reasons.size - 2} more" if reasons.size > 2
273
+ @diagnostics.warn("#{path}: list the constant in c.whole_file to replay it anyway, or move the " \
274
+ "literal into its own file\n --since: constant subject(s) in this file are not " \
275
+ "mutated: it runs code at load (#{listed}) that every constant mutant would run " \
276
+ "again, and saboteur cannot vet what re-running it does. " \
275
277
  "#{whole_file_id_hint(rest)}")
276
278
  opted_in
277
279
  end
@@ -303,15 +305,15 @@ module Saboteur
303
305
  @config["whole_file"].each do |pattern|
304
306
  next if subjects.any? { |subject| File.fnmatch?(pattern.to_s, subject.id) }
305
307
 
306
- @diagnostics.warn("--since: whole_file pattern \"#{pattern}\" matches no discovered " \
307
- "subject in this run; subject ids look like RULES, A::B::C or A::B#m")
308
+ @diagnostics.warn("fix the whole_file entry \"#{Text.display_safe(pattern.to_s)}\": it matches no " \
309
+ "discovered subject in this run (subject ids look like RULES, A::B::C or A::B#m)")
308
310
  end
309
311
  end
310
312
 
311
313
  def warn_unsupported_constant(found)
312
- path = found.file.delete_prefix("#{@root}/")
313
- @diagnostics.warn("--since: changed constant #{found.id} in #{path} is not mutated: #{found.reason}. " \
314
- "Only hash and array literal entries are mutated.")
314
+ path = Text.display_safe(found.file.delete_prefix("#{@root}/"))
315
+ @diagnostics.warn("--since: changed constant #{found.id} in #{path} is not mutated\n " \
316
+ "#{found.reason}; only hash and array literal entries are mutated")
315
317
  end
316
318
 
317
319
  # The tests that can observe a constant mutant are the ones covering a
@@ -330,9 +332,9 @@ module Saboteur
330
332
  subject.consumer_defs = reads.transform_values(&:defs)
331
333
  if subject.consumer_lines.empty? && reads.any? { |_file, r| r.load.any? }
332
334
  path = subject.file.delete_prefix("#{@root}/")
333
- @diagnostics.warn("--since: changed constant #{subject.id} in #{path} is not mutated: its only " \
334
- "reads capture the value at load time in another file, so no test can " \
335
- "observe a change to it.")
335
+ @diagnostics.warn("--since: changed constant #{subject.id} in #{path} is not mutated\n " \
336
+ "its only reads capture the value at load time in another file, so no test " \
337
+ "can observe a change to it")
336
338
  next
337
339
  end
338
340
  subject
@@ -395,11 +397,10 @@ module Saboteur
395
397
  unmatched = (@changed_lines.keys.select { |file| file.end_with?(".rb") } & included) - matched
396
398
  return if unmatched.empty?
397
399
 
398
- names = unmatched.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
399
- more = unmatched.size > 5 ? ", ..." : ""
400
+ names = Text.name_list(unmatched.map { |file| file.delete_prefix("#{@root}/") })
400
401
  note = constant_note(unmatched)
401
402
  @diagnostics.warn("--since: #{unmatched.size} changed Ruby file(s) have no subjects covering " \
402
- "the changed lines, so nothing is mutated from them: #{names}#{more}. " \
403
+ "the changed lines, so nothing is mutated from them: #{names}\n " \
403
404
  "#{subjectless_hint(unmatched)}#{note}")
404
405
  end
405
406
 
@@ -410,7 +411,7 @@ module Saboteur
410
411
  constants = unmatched.count { |file| constant_warned_files.include?(file) }
411
412
  return "" if constants.zero?
412
413
 
413
- " #{constants} of these #{constants == 1 ? 'file has' : 'files have'} changes inside " \
414
+ "\n #{constants} of these #{constants == 1 ? 'file has' : 'files have'} changes inside " \
414
415
  "a constant literal that is not mutated (the constant warnings above name them)."
415
416
  end
416
417
 
@@ -423,9 +424,8 @@ module Saboteur
423
424
  embedded = unmatched.select { |file| changed_lines_inside_heredoc?(file) }
424
425
  return "The file may not parse, or every change may sit outside a method." if embedded.empty?
425
426
 
426
- names = embedded.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
427
- more = embedded.size > 5 ? ", ..." : ""
428
- "In #{names}#{more} the changed lines are code embedded in a string (a heredoc, often the " \
427
+ names = Text.name_list(embedded.map { |file| file.delete_prefix("#{@root}/") })
428
+ "In #{names}, the changed lines are code embedded in a string (a heredoc, often the " \
429
429
  "argument to class_eval/module_eval or a template constant); methods defined by evaluating " \
430
430
  "strings are not mutation subjects, so this run cannot see that behavior."
431
431
  end
@@ -443,12 +443,11 @@ module Saboteur
443
443
  hits = excluded_territory & (@changed_lines.keys + untracked)
444
444
  return if hits.empty?
445
445
 
446
- names = hits.sort.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
447
- more = hits.size > 5 ? ", ..." : ""
448
- @diagnostics.warn("--since: #{hits.size} changed Ruby file(s) are covered by c.include but removed by " \
449
- "c.exclude, so nothing is mutated from them: #{names}#{more}. Narrow or remove the " \
450
- "c.exclude entry, or pass the file as a positional source operand (configured " \
451
- "excludes are then ignored).")
446
+ names = Text.name_list(hits.sort.map { |file| file.delete_prefix("#{@root}/") })
447
+ @diagnostics.warn("narrow or remove the c.exclude entry, or pass the file as a positional source " \
448
+ "operand: #{hits.size} changed Ruby file(s) are covered by c.include but removed " \
449
+ "by c.exclude\n --since: nothing is mutated from them: #{names} (configured " \
450
+ "excludes are then ignored)")
452
451
  end
453
452
 
454
453
  # Ruby files an include pattern and an exclude pattern both match,
@@ -473,10 +472,9 @@ module Saboteur
473
472
  end
474
473
  return subjects if dropped.empty?
475
474
 
476
- names = dropped.first(5).map(&:id).join(", ")
477
- more = dropped.size > 5 ? ", ..." : ""
475
+ names = Text.name_list(dropped)
478
476
  @diagnostics.inform("#{dropped.size} subject(s) skipped: intentionally untested in " \
479
- "config/saboteur_custom_map.rb: #{names}#{more}")
477
+ "config/saboteur_custom_map.rb: #{names}")
480
478
  kept
481
479
  end
482
480
 
@@ -501,19 +499,17 @@ module Saboteur
501
499
  def report_deleted(deleted)
502
500
  return if deleted.empty?
503
501
 
504
- names = deleted.first(5).map(&:id).join(", ")
505
- more = deleted.size > 5 ? ", ..." : ""
502
+ names = Text.name_list(deleted)
506
503
  verb = deleted.one? ? "subject was" : "subjects were"
507
- @diagnostics.inform("#{deleted.size} changed #{verb} deleted, nothing to mutate: #{names}#{more}")
504
+ @diagnostics.inform("#{deleted.size} changed #{verb} deleted, nothing to mutate: #{names}")
508
505
  end
509
506
 
510
507
  def report_unchanged_syntax(subjects)
511
508
  return if subjects.empty?
512
509
 
513
- names = subjects.first(5).map(&:id).join(", ")
514
- more = subjects.size > 5 ? ", ..." : ""
510
+ names = Text.name_list(subjects)
515
511
  @diagnostics.inform("#{subjects.size} changed subject(s) differ only in comments or formatting, " \
516
- "so they are skipped: #{names}#{more}")
512
+ "so they are skipped: #{names}")
517
513
  end
518
514
 
519
515
  def canonical_path(file)
@@ -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
@@ -57,9 +57,10 @@ module Saboteur
57
57
  # in specs; skipped_specs: spec files the diff touches that cannot run,
58
58
  # each with the reason.
59
59
  Result = Data.define(:specs, :unmatched, :subjectless, :changed, :hints, :changed_specs,
60
- :skipped_specs, :out_of_scope, :excluded, :near, :no_suite) do
60
+ :skipped_specs, :out_of_scope, :excluded, :near, :no_suite, :without_map_entry,
61
+ :referenced, :crowded) do
61
62
  def initialize(changed_specs: [], skipped_specs: {}, out_of_scope: [], excluded: [], near: {},
62
- no_suite: nil, **fields)
63
+ no_suite: nil, without_map_entry: [], referenced: {}, crowded: {}, **fields)
63
64
  super
64
65
  end
65
66
 
@@ -74,10 +75,16 @@ module Saboteur
74
75
  end
75
76
 
76
77
  pairs = unmatched.map { |file| pair_description(file) }
77
- 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."
78
85
  "no spec found for #{pairs.join(', ')}, so nothing ran.#{changed} Re-run the same command with " \
79
86
  "one --test per existing spec path, or append --all to use the whole suite without " \
80
- "changing the source scope."
87
+ "changing the source scope.#{mapped}"
81
88
  end
82
89
 
83
90
  private
@@ -85,22 +92,31 @@ module Saboteur
85
92
  def pair_description(file)
86
93
  expected = hints.fetch(file)
87
94
  gone = skipped_specs[expected] == DELETED ? ", deleted in the diff" : ""
88
- found = near.fetch(file, []).any? ? "; found: #{near.fetch(file).join(', ')}" : ""
89
- "#{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})"
90
98
  end
91
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
+
92
104
  public
93
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
+
94
110
  # The spec files the diff touched that do not run, with the reasons.
95
111
  def skipped_message
96
- named = skipped_specs.map { |path, reason| "#{path} (#{reason})" }
112
+ named = skipped_specs.map { |path, reason| "#{Text.display_safe(path)} (#{reason})" }
97
113
  "inferred specs: not running spec files changed in the diff: #{named.join(', ')}"
98
114
  end
99
115
 
100
116
  # Why the subjectless files needed no spec.
101
117
  def subjectless_message(since:)
102
118
  detail = since ? "no changed mutation subjects" : "no mutation subjects"
103
- "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}"
104
120
  end
105
121
 
106
122
  # The per-file warning when the run proceeds with the mapped
@@ -117,7 +133,7 @@ module Saboteur
117
133
  # scope hid. Append-only recovery, like unmatched_message: extend include, or
118
134
  # name the specs with --test.
119
135
  def out_of_scope_message
120
- named = out_of_scope.first(10).join(", ")
136
+ named = names(out_of_scope.first(10))
121
137
  more = out_of_scope.size - 10
122
138
  named += " and #{more} more" if more.positive?
123
139
  "no changed source file lies inside the include scope, so nothing ran. " \
@@ -140,7 +156,7 @@ module Saboteur
140
156
 
141
157
  # Why the custom-map-excluded files needed no spec (#369).
142
158
  def excluded_message
143
- "inferred specs: no spec is required for #{excluded.join(', ')}: " \
159
+ "inferred specs: no spec is required for #{names(excluded)}: " \
144
160
  "intentionally untested in config/saboteur_custom_map.rb"
145
161
  end
146
162
 
@@ -248,20 +264,35 @@ module Saboteur
248
264
  end
249
265
  end
250
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
+
251
280
  # Changed specs join only a run that mutates something: with no file
252
281
  # in scope there is nothing for them to kill.
253
282
  def result(guesses, subjectless, changed, out_of_scope: [], subject_run: false)
254
283
  unmatched = guesses.select { |_, specs| specs.nil? }.keys
255
284
  excluded = guesses.select { |_, specs| specs == :excluded }.keys.sort
256
285
  inferred = guesses.values.grep(Array).flatten
257
- diff = @config["since"] && guesses.any? ? specs_in_diff(inferred) : { run: [], skipped: {} }
286
+ diff = diff_specs(inferred, guesses)
258
287
  unmatched -= covered_by_changed_specs(unmatched, diff[:run])
259
- 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,
260
291
  unmatched: unmatched,
292
+ without_map_entry: without_map_entry(unmatched),
261
293
  subjectless: subjectless,
262
294
  changed: changed,
263
- hints: unmatched.to_h { |file| [file, expected_spec(file, diff[:skipped])] },
264
- near: unmatched.to_h { |file| [file, nearby_specs(file)] },
295
+ **stop_details(unmatched, diff[:skipped]),
265
296
  changed_specs: diff[:run],
266
297
  skipped_specs: diff[:skipped],
267
298
  out_of_scope: out_of_scope,
@@ -269,6 +300,13 @@ module Saboteur
269
300
  no_suite: NoSuiteStop.layout(@root, unmatched, subject_run))
270
301
  end
271
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 }
308
+ end
309
+
272
310
  # Changed Ruby files the include scope hides (#483), relative to the
273
311
  # root: the diff touched source, but none of it lands inside include.
274
312
  # A changed spec is a test, not hidden source; a spec-only diff keeps
@@ -314,6 +352,26 @@ module Saboteur
314
352
  end
315
353
  end
316
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
+
317
375
  # The constants a file defines: owners of its def subjects
318
376
  # ("JWT::Claims::DecodeVerifier" from "...#call") and its constant
319
377
  # literal subjects. Shaped for ConstantReads, which matches a bare
@@ -458,7 +516,9 @@ module Saboteur
458
516
  def guess_for(file)
459
517
  mapped = @custom_map.specs_for(file)
460
518
  return :excluded if mapped == :excluded
461
- return expand_mapped(file, mapped) if mapped
519
+
520
+ expanded = mapped && expand_mapped(file, mapped)
521
+ return expanded if expanded
462
522
 
463
523
  conventional = candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
464
524
  return [conventional] if conventional
@@ -471,8 +531,9 @@ module Saboteur
471
531
  # backreference filled at match time, and neither is validated to
472
532
  # exist when the map loads. Only specs that exist run; the literal
473
533
  # glob string never reaches RSpec. When the winning entry's specs all
474
- # vanish the mapping is broken: stop naming it rather than silently
475
- # 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).
476
537
  def expand_mapped(file, specs)
477
538
  expanded = specs.flat_map do |spec|
478
539
  if spec.match?(/[*?\[{]/)
@@ -484,7 +545,10 @@ module Saboteur
484
545
  expanded = expanded.select { |path| File.file?(File.join(@root, path)) }
485
546
  return expanded unless expanded.empty?
486
547
 
487
- 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
488
552
  end
489
553
 
490
554
  # Candidate specs for a source file, most specific first: the nearest
@@ -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.
@@ -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,7 +274,8 @@ 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)
@@ -309,15 +323,18 @@ module Saboteur
309
323
 
310
324
  def result_cache(map = nil, files = [])
311
325
  return nil if @replay
312
- return nil unless config["incremental"]
326
+ return nil unless config["incremental"] || config["resume"]
313
327
 
314
- inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }
315
- .merge("operator_policy" => config.operator_policy))
316
- 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,
317
330
  suite_digest: SuiteFiles.new(config).digest,
318
331
  coverage: map, fallback_files: source_files(files))
319
332
  end
320
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
+
321
338
  # A cleanup failure must never mask the run's own outcome - not even
322
339
  # one cleanup_workers did not anticipate.
323
340
  def safely_cleanup_workers(rails)
@@ -366,7 +383,8 @@ module Saboteur
366
383
 
367
384
  candidates = Candidates.build(config, @diagnostics, hazard_guard: hazard_guard)
368
385
  selection = CandidateSelection.new(candidates: candidates, map: map,
369
- limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
386
+ limit: config["max_mutants_per_method"], diagnostics: @diagnostics,
387
+ root: @root)
370
388
  lists = subjects.map { |subject| selection.for(subject, scope: scope) }
371
389
  @shard = Engine::Shard.parse(config["shard"]) if config["shard"]
372
390
  lists = @shard.select(subjects, lists) if @shard
@@ -428,6 +446,9 @@ module Saboteur
428
446
 
429
447
  def suspend_coverage(files)
430
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
431
452
  LoadCoverage.capture(files)
432
453
  end
433
454
 
@@ -441,8 +462,18 @@ require_relative "engine/boot"
441
462
  require_relative "engine/subject_selection"
442
463
  require_relative "engine/unchanged_syntax"
443
464
  require_relative "engine/suite_files"
465
+ require_relative "engine/reference_specs"
444
466
  require_relative "engine/test_guess"
445
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"
446
477
  require_relative "engine/coverage_store"
447
478
  require_relative "engine/baseline_guard"
448
479
  require_relative "engine/unscoped_suite_warning"
@@ -451,6 +482,7 @@ require_relative "engine/candidates"
451
482
  require_relative "engine/candidate_selection"
452
483
  require_relative "engine/diff_scope"
453
484
  require_relative "engine/result_cache"
485
+ require_relative "engine/resume_manifest"
454
486
  require_relative "engine/defined_order_prefix"
455
487
  require_relative "engine/verdict_check"
456
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