saboteur 0.8.0 → 0.8.1

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -0
  3. data/README.md +34 -6
  4. data/lib/saboteur/attribute_finder.rb +113 -0
  5. data/lib/saboteur/cli/presets.rb +41 -1
  6. data/lib/saboteur/cli/run_budget_options.rb +3 -1
  7. data/lib/saboteur/cli/source_set.rb +0 -18
  8. data/lib/saboteur/cli.rb +4 -2
  9. data/lib/saboteur/config.rb +4 -1
  10. data/lib/saboteur/config_file.rb +12 -8
  11. data/lib/saboteur/custom_map.rb +12 -1
  12. data/lib/saboteur/dirty_tree_warning.rb +5 -3
  13. data/lib/saboteur/doctor.rb +4 -0
  14. data/lib/saboteur/engine/baseline_guard.rb +76 -10
  15. data/lib/saboteur/engine/boot.rb +7 -3
  16. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  17. data/lib/saboteur/engine/candidate_selection.rb +1 -0
  18. data/lib/saboteur/engine/coverage_store.rb +25 -3
  19. data/lib/saboteur/engine/diagnostics.rb +1 -1
  20. data/lib/saboteur/engine/gated_run.rb +3 -1
  21. data/lib/saboteur/engine/load_coverage.rb +18 -0
  22. data/lib/saboteur/engine/pipelines.rb +8 -0
  23. data/lib/saboteur/engine/run_budget.rb +5 -2
  24. data/lib/saboteur/engine/sandbox_policy.rb +21 -3
  25. data/lib/saboteur/engine/scheduler.rb +32 -24
  26. data/lib/saboteur/engine/selection_announcement.rb +8 -3
  27. data/lib/saboteur/engine/subject_selection.rb +115 -31
  28. data/lib/saboteur/engine/test_guess.rb +106 -16
  29. data/lib/saboteur/engine.rb +3 -2
  30. data/lib/saboteur/execution/external_runner.rb +18 -0
  31. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  32. data/lib/saboteur/execution/marker_sweep.rb +2 -1
  33. data/lib/saboteur/execution/process_table.rb +7 -3
  34. data/lib/saboteur/execution/reevaluator.rb +7 -0
  35. data/lib/saboteur/execution/sandbox.rb +10 -4
  36. data/lib/saboteur/execution/seatbelt.rb +10 -3
  37. data/lib/saboteur/execution/supervisor.rb +2 -1
  38. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  39. data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
  40. data/lib/saboteur/integration/rspec.rb +99 -11
  41. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  42. data/lib/saboteur/mutation_source/builtin.rb +3 -1
  43. data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
  44. data/lib/saboteur/mutation_source/operator_examples.rb +1 -0
  45. data/lib/saboteur/mutation_source/operator_registry.rb +1 -0
  46. data/lib/saboteur/preview.rb +5 -0
  47. data/lib/saboteur/reporter/agent_incomplete.rb +20 -5
  48. data/lib/saboteur/reporter/terminal.rb +16 -1
  49. data/lib/saboteur/selection/coverage_map.rb +17 -2
  50. data/lib/saboteur/version.rb +1 -1
  51. data/lib/saboteur.rb +1 -0
  52. metadata +5 -15
@@ -4,12 +4,39 @@
4
4
  require_relative "../text"
5
5
  require_relative "../constant_subject"
6
6
  require_relative "../constant_subject_finder"
7
+ require_relative "../attribute_finder"
7
8
  require_relative "../constant_reads"
8
9
 
9
10
  module Saboteur
10
11
  class Engine
11
12
  # The subjects to mutate: every def in the included files, narrowed by
12
13
  # --subject patterns and by --since (methods with a changed line).
14
+ # A heredoc body is code the static subject walk cannot see: methods
15
+ # defined by class_eval'ing the string, or behavior living in a
16
+ # template constant, exist only when the string is evaluated (#467).
17
+ module HeredocSpans
18
+ module_function
19
+
20
+ def changed_lines_inside?(file, changed_lines)
21
+ result = Prism.parse(File.binread(file))
22
+ return false if result.failure?
23
+
24
+ spans = collect(result.value)
25
+ changed_lines.any? { |line| spans.any? { |span| span.cover?(line) } }
26
+ end
27
+
28
+ def collect(node, spans = [])
29
+ if (node.is_a?(Prism::StringNode) || node.is_a?(Prism::InterpolatedStringNode)) &&
30
+ node.opening_loc&.slice&.start_with?("<<")
31
+ # The node location of an interpolated string stops at the opening
32
+ # marker; opening through closing covers the whole heredoc body.
33
+ spans << (node.opening_loc.start_line..node.closing_loc.end_line)
34
+ end
35
+ node.compact_child_nodes.each { |child| collect(child, spans) }
36
+ spans
37
+ end
38
+ end
39
+
13
40
  class SubjectSelection
14
41
  # Lines changed since the --since ref, by file; nil without --since.
15
42
  attr_reader :changed_lines
@@ -33,12 +60,36 @@ module Saboteur
33
60
  subjects += constant_subjects
34
61
  end
35
62
  @discovered = subjects
63
+ warn_unmatched_whole_file(subjects) if @config["since"]
36
64
  subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
37
65
  subjects = by_change(subjects, @config["since"]) if @config["since"]
38
66
  subjects = drop_intentionally_untested(subjects)
67
+ inform_nothing_to_test(subjects)
39
68
  attach_consumers(subjects)
40
69
  end
41
70
 
71
+ # A --since run can legitimately select nothing: the range is empty
72
+ # (a checkout AT the base, #643) or its changes carry no mutable Ruby
73
+ # (a docs-only diff, #659). Exit 0 with a notice, not a silent green:
74
+ # name the base, the changed-file count and why nothing was selected.
75
+ def inform_nothing_to_test(subjects)
76
+ ref = @config["since"]
77
+ return unless ref && subjects.empty?
78
+
79
+ changed = Selection::GitDiff.changed_paths(ref, root: @root)
80
+ count = changed[:present].size + changed[:deleted].size
81
+ reason = if count.zero?
82
+ "the resolved range has no changes"
83
+ elsif !@raw_changed_files.intersect?(@files)
84
+ "#{count} changed file#{'s' unless count == 1}, none in mutation scope " \
85
+ "(docs-only, excluded, or non-Ruby)"
86
+ else
87
+ "no subject covers the changed lines (layout-only edits, deletions, " \
88
+ "or intentionally untested code)"
89
+ end
90
+ @diagnostics.inform("--since #{ref}: nothing to test - #{reason}")
91
+ end
92
+
42
93
  # "Klass#meth", "Klass.meth", or a namespace prefix ("Klass", "A::B").
43
94
  def self.match?(id, pattern)
44
95
  id == pattern || %w[# . ::].any? { |separator| id.start_with?("#{pattern}#{separator}") }
@@ -96,16 +147,12 @@ module Saboteur
96
147
  subjects.any? { |subject| self.class.match?(subject.id, pattern) }
97
148
  end
98
149
  missed.each do |pattern|
99
- @diagnostics.warn("--subject #{pattern} matches no subject (patterns are names like " \
100
- "Klass, Klass#meth or Klass.meth, not globs)")
150
+ @diagnostics.warn(missed_pattern_message(pattern))
101
151
  end
102
152
  # A total miss is a typo: refuse instead of a green zero-subject
103
153
  # summary (#379). A partial miss runs, but the matched count is
104
154
  # on the record.
105
- if missed.size == patterns.size
106
- raise ConfigError, "--subject #{patterns.join(', ')} matches no subject (patterns are names like " \
107
- "Klass, Klass#meth or Klass.meth, not globs)"
108
- end
155
+ raise ConfigError, total_miss_message(missed) if missed.size == patterns.size
109
156
 
110
157
  if missed.any?
111
158
  @diagnostics.warn("#{patterns.size - missed.size} of #{patterns.size} --subject patterns matched")
@@ -113,6 +160,37 @@ module Saboteur
113
160
  subjects.select { |subject| patterns.any? { |pattern| self.class.match?(subject.id, pattern) } }
114
161
  end
115
162
 
163
+ # The refusal when every pattern missed (#379). A pattern naming an
164
+ # attribute-macro method gets the real reason - the method exists but
165
+ # has no body to mutate (#373) - instead of the pattern-format hint.
166
+ def total_miss_message(missed)
167
+ if missed.all? { |pattern| attribute_macro_for(pattern) }
168
+ return missed.map { |pattern| missed_pattern_message(pattern) }.join("; ")
169
+ end
170
+
171
+ "--subject #{missed.join(', ')} matches no subject (patterns are names like " \
172
+ "Klass, Klass#meth or Klass.meth, not globs)"
173
+ end
174
+
175
+ # Why one missed pattern matched nothing: when it names a method an
176
+ # attribute macro generated, say so (#373); otherwise the pattern
177
+ # itself is suspect.
178
+ def missed_pattern_message(pattern)
179
+ macro = attribute_macro_for(pattern)
180
+ return "--subject #{pattern} is defined by #{macro} and has no body to mutate" if macro
181
+
182
+ "--subject #{pattern} matches no subject (patterns are names like " \
183
+ "Klass, Klass#meth or Klass.meth, not globs)"
184
+ end
185
+
186
+ # The attribute macro that generated the method a pattern names, if
187
+ # any. The files are parsed for macros once, only when a pattern
188
+ # missed.
189
+ def attribute_macro_for(pattern)
190
+ attributes = @attributes ||= @files.flat_map { |file| AttributeFinder.new.call(file) }
191
+ attributes.find { |found| found.id == pattern }&.macro
192
+ end
193
+
116
194
  def by_change(subjects, ref)
117
195
  changed = changed_subjects(subjects)
118
196
  warn_changed_files_without_subjects
@@ -193,7 +271,8 @@ module Saboteur
193
271
  @diagnostics.warn("--since: constant subject(s) in #{path} are not mutated: the file runs code at " \
194
272
  "load (#{listed}) that every constant mutant would run again, and saboteur " \
195
273
  "cannot vet what re-running it does. List the constant in c.whole_file to " \
196
- "replay the file anyway, or move the literal into its own file.")
274
+ "replay the file anyway, or move the literal into its own file. " \
275
+ "#{whole_file_id_hint(rest)}")
197
276
  opted_in
198
277
  end
199
278
 
@@ -203,6 +282,32 @@ module Saboteur
203
282
  Array(@config["whole_file"]).any? { |pattern| File.fnmatch?(pattern.to_s, subject.id) }
204
283
  end
205
284
 
285
+ # A bare constant name only ever matches a top-level constant's id -
286
+ # never a nested one - so name the exact ids to copy into c.whole_file.
287
+ # The glob example carries its blast radius: it opts in every matching
288
+ # subject project-wide, so the exact id(s) are the safer pick (#635).
289
+ def whole_file_id_hint(subjects)
290
+ ids = subjects.map(&:id)
291
+ named = ids.first(3).map { |id| "\"#{id}\"" }.join(", ")
292
+ named += ", +#{ids.size - 3} more" if ids.size > 3
293
+ "Exact id(s): #{named} (preferred); a glob like \"*#{ids.first.split('::').last}*\" also " \
294
+ "works but opts in every matching subject project-wide."
295
+ end
296
+
297
+ # A whole_file pattern that matches nothing silently does nothing (#635):
298
+ # name the pattern and what subject ids look like, so a bare constant
299
+ # name for a nested constant (which never matches its qualified id)
300
+ # stands out. Scoped to this run: a pattern for a file outside the
301
+ # current include set also matches nothing.
302
+ def warn_unmatched_whole_file(subjects)
303
+ @config["whole_file"].each do |pattern|
304
+ next if subjects.any? { |subject| File.fnmatch?(pattern.to_s, subject.id) }
305
+
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
+ end
309
+ end
310
+
206
311
  def warn_unsupported_constant(found)
207
312
  path = found.file.delete_prefix("#{@root}/")
208
313
  @diagnostics.warn("--since: changed constant #{found.id} in #{path} is not mutated: #{found.reason}. " \
@@ -325,30 +430,9 @@ module Saboteur
325
430
  "strings are not mutation subjects, so this run cannot see that behavior."
326
431
  end
327
432
 
328
- # A heredoc body is code the static subject walk cannot see: methods
329
- # defined by class_eval'ing the string, or behavior living in a
330
- # template constant, exist only when the string is evaluated (#467).
331
- # Changed lines inside one deserve the specific warning, not the
332
- # generic parse/method guess. A file that does not parse keeps the
333
- # generic hint: its problem is named there already.
334
- def changed_lines_inside_heredoc?(file)
335
- result = Prism.parse(File.binread(file))
336
- return false if result.failure?
337
-
338
- spans = heredoc_spans(result.value)
339
- @changed_lines[file].any? { |line| spans.any? { |span| span.cover?(line) } }
340
- end
341
-
342
- def heredoc_spans(node, spans = [])
343
- if (node.is_a?(Prism::StringNode) || node.is_a?(Prism::InterpolatedStringNode)) &&
344
- node.opening_loc&.slice&.start_with?("<<")
345
- # The node location of an interpolated string stops at the opening
346
- # marker; opening through closing covers the whole heredoc body.
347
- spans << (node.opening_loc.start_line..node.closing_loc.end_line)
348
- end
349
- node.compact_child_nodes.each { |child| heredoc_spans(child, spans) }
350
- spans
351
- end
433
+ # Changed lines inside a heredoc deserve the specific warning, not the
434
+ # generic parse/method guess (#467); the scan lives in HeredocSpans.
435
+ def changed_lines_inside_heredoc?(file) = HeredocSpans.changed_lines_inside?(file, @changed_lines[file])
352
436
 
353
437
  # A changed Ruby file that an include pattern covers but c.exclude
354
438
  # removes yields zero candidates with no other sign - this repo's own
@@ -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,9 @@ 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) do
61
+ def initialize(changed_specs: [], skipped_specs: {}, out_of_scope: [], excluded: [], near: {},
62
+ no_suite: nil, **fields)
30
63
  super
31
64
  end
32
65
 
@@ -35,7 +68,11 @@ module Saboteur
35
68
  # command, which could carry a hostile ref or a wrong source scope.
36
69
  # When the diff deleted the expected spec or added others, it says
37
70
  # so: a renamed spec is the usual cause.
38
- def unmatched_message
71
+ def unmatched_message(verbose: false)
72
+ if no_suite
73
+ return NoSuiteStop.message(no_suite, pairs: unmatched.map { |f| pair_description(f) }, verbose: verbose)
74
+ end
75
+
39
76
  pairs = unmatched.map { |file| pair_description(file) }
40
77
  changed = changed_specs.any? ? " Specs the diff adds or changes: #{changed_specs.join(', ')}." : ""
41
78
  "no spec found for #{pairs.join(', ')}, so nothing ran.#{changed} Re-run the same command with " \
@@ -148,7 +185,9 @@ module Saboteur
148
185
  def call_for(files, patterns = [])
149
186
  files = effective_subject_files(files, patterns) if @config["since"]
150
187
  guesses = files.to_h { |file| [file, guess_for(file)] }
151
- result(guesses, [], files.size)
188
+ # A --subject run already knows its suite: the stop names the one
189
+ # expected spec, never the no-suite line (#606 is the full-run flood).
190
+ result(guesses, [], files.size, subject_run: true)
152
191
  end
153
192
 
154
193
  # specs: existing spec files (relative paths) guessed for the files in
@@ -211,7 +250,7 @@ module Saboteur
211
250
 
212
251
  # Changed specs join only a run that mutates something: with no file
213
252
  # in scope there is nothing for them to kill.
214
- def result(guesses, subjectless, changed, out_of_scope: [])
253
+ def result(guesses, subjectless, changed, out_of_scope: [], subject_run: false)
215
254
  unmatched = guesses.select { |_, specs| specs.nil? }.keys
216
255
  excluded = guesses.select { |_, specs| specs == :excluded }.keys.sort
217
256
  inferred = guesses.values.grep(Array).flatten
@@ -226,7 +265,8 @@ module Saboteur
226
265
  changed_specs: diff[:run],
227
266
  skipped_specs: diff[:skipped],
228
267
  out_of_scope: out_of_scope,
229
- excluded: excluded)
268
+ excluded: excluded,
269
+ no_suite: NoSuiteStop.layout(@root, unmatched, subject_run))
230
270
  end
231
271
 
232
272
  # Changed Ruby files the include scope hides (#483), relative to the
@@ -303,13 +343,17 @@ module Saboteur
303
343
  # --test without hunting (#370). Bounded; computed only for
304
344
  # unmatched files, which are about to stop or warn.
305
345
  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}" }
346
+ basename = escape_glob(File.basename(file, ".rb"))
309
347
  Dir.glob("spec/**/#{basename}_spec.rb", base: @root)
310
348
  .select { |path| File.file?(File.join(@root, path)) }.sort.first(3)
311
349
  end
312
350
 
351
+ # A path segment is data, not a pattern: [, ], {, }, ?, * and \ are
352
+ # glob metacharacters and must be escaped to match literally.
353
+ def escape_glob(segment)
354
+ segment.gsub(/[\[\]{}?*\\]/) { |char| "\\#{char}" }
355
+ end
356
+
313
357
  # lstat, not stat: a symlink can point anywhere, including a spec of
314
358
  # another application or a fixture the suite excludes.
315
359
  def unrunnable_reason(path)
@@ -417,7 +461,10 @@ module Saboteur
417
461
  return expand_mapped(file, mapped) if mapped
418
462
 
419
463
  conventional = candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
420
- conventional && [conventional]
464
+ return [conventional] if conventional
465
+
466
+ unique = unique_suffix_spec(file)
467
+ unique && [unique]
421
468
  end
422
469
 
423
470
  # A mapped spec can be a glob (expanded against the root) or a regexp
@@ -464,14 +511,31 @@ module Saboteur
464
511
 
465
512
  # Layouts that mirror less of the source path, after every mirrored
466
513
  # 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.
514
+ # it, #393), directories dropped between the gem namespace and the
515
+ # file (rubocop-ast tests lib/rubocop/ast/node/block_node.rb in
516
+ # spec/rubocop/ast/block_node_spec.rb, #586), the basename at the
517
+ # spec root (pundit), and both together (aasm, #370). Weaker
518
+ # guesses come last: the first candidate that exists wins. A file
519
+ # outside the source roots keeps its literal guess only: dropping
520
+ # directories from a path the roots never anchored would guess any
521
+ # same-named spec.
472
522
  def layout_variants(names)
473
523
  flat = names.map { |name| name.split("/").last }.uniq
474
- (names + names.map { |name| "unit/#{name}" } + flat + flat.map { |name| "unit/#{name}" }).uniq
524
+ (names + names.map { |name| "unit/#{name}" } + flattened_below_namespace(names) + flat +
525
+ flat.map { |name| "unit/#{name}" }).uniq
526
+ end
527
+
528
+ # Every way to drop mid directories from a name, fewest dropped
529
+ # first: each guess keeps at least the leading namespace segment
530
+ # and the basename, so it stays anchored on the gem namespace and
531
+ # more specific than the bare basename flatten (#586).
532
+ def flattened_below_namespace(names)
533
+ names.flat_map do |name|
534
+ segments = name.split("/")
535
+ (segments.size - 2).downto(1).map do |keep|
536
+ (segments.first(keep) + [segments.last]).join("/")
537
+ end
538
+ end.uniq
475
539
  end
476
540
 
477
541
  # Rails tests controllers as request specs: spec/requests/users_spec.rb
@@ -483,6 +547,32 @@ module Saboteur
483
547
  ["requests/#{path}", "requests/#{path.delete_suffix('_controller')}"].uniq
484
548
  end
485
549
 
550
+ # The last-resort guess when no named candidate exists (#600): a
551
+ # spec tree can strip directories the convention keeps - aasm tests
552
+ # lib/aasm/core/invokers/proc_invoker.rb in
553
+ # spec/unit/invokers/proc_invoker_spec.rb, dropping the aasm and
554
+ # core segments. The one anchor such a layout keeps is the file's
555
+ # parent directory and basename, so a spec under the spec root
556
+ # ending in that suffix is the guess - but only a unique one: a
557
+ # second match makes the layout ambiguous, and guessing would run
558
+ # the wrong specs, a false verdict worse than the stop, whose
559
+ # near-miss hint names the candidates. Only files under a source
560
+ # root with a real parent directory qualify: a bare basename can
561
+ # collide anywhere, so it stays a near-miss hint.
562
+ def unique_suffix_spec(file)
563
+ pack = pack_of(file)
564
+ stem = file.delete_prefix("#{pack}/").delete_suffix(".rb")
565
+ root_segment, _, name = stem.partition("/")
566
+ return nil unless SOURCE_ROOTS.include?(root_segment) && name.include?("/")
567
+
568
+ suffix = name.split("/").last(2).map { |segment| escape_glob(segment) }.join("/")
569
+ spec_root = pack ? "#{pack}/spec" : "spec"
570
+ matches = Dir.glob(File.join(@root, spec_root, "**", "#{suffix}_spec.rb"))
571
+ .select { |path| File.file?(path) }
572
+ .map { |path| path.delete_prefix("#{@root}/") }.sort
573
+ matches.one? ? matches.first : nil
574
+ end
575
+
486
576
  # The nearest ancestor directory holding a package.yml (a packwerk
487
577
  # pack), relative to the root. The root itself is not a pack here:
488
578
  # Pathname#ascend stops above "." for these relative paths.
@@ -136,7 +136,7 @@ module Saboteur
136
136
  return if guess.changed.zero?
137
137
 
138
138
  inform_skipped_specs(guess)
139
- raise ConfigError, guess.unmatched_message if guess.unmatched.any?
139
+ raise ConfigError, guess.unmatched_message(verbose: @diagnostics.verbose) if guess.unmatched.any?
140
140
 
141
141
  @diagnostics.inform(guess.excluded_message) if guess.excluded.any?
142
142
  # Every file in scope intentionally untested: mutate nothing. An
@@ -268,7 +268,8 @@ module Saboteur
268
268
  guard = BaselineGuard.new(config: config, integration: integration, hooks: hooks,
269
269
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
270
270
  full_passes: full_passes, timeout_cap: method(:budget_capped_timeout),
271
- between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil)
271
+ between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil,
272
+ run_started: @run_started)
272
273
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
273
274
  guard.call
274
275
  @baseline_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
@@ -335,6 +335,7 @@ module Saboteur
335
335
  if defined?(Bundler)
336
336
  original = Bundler.original_env
337
337
  BUNDLER_PASSTHROUGH_KEYS.each { |key| env[key] = original[key] if original.key?(key) }
338
+ restore_dropped_rubylib_entries(env, original)
338
339
  end
339
340
  if (rubyopt = env["RUBYOPT"])
340
341
  rubyopt = rubyopt.gsub(%r{\s*-rbundler/setup}, "").strip
@@ -343,6 +344,23 @@ module Saboteur
343
344
  env.merge(CHILD_MARKER => "1")
344
345
  end
345
346
 
347
+ # bundler <=2.5 scrubs even shell-exported entries (verified
348
+ # 2.5.22); bundler >=2.6.0 restores the pre-bundler value wholesale
349
+ # (verified 2.6.0/2.6.9), where this restore is a no-op. A
350
+ # relocated/toolcache Ruby needs that entry to find its stdlib
351
+ # (#584). Rebuild the list from the pre-bundler one - RUBYLIB is
352
+ # ordered, and two directories providing the same feature load in
353
+ # path order - then append entries only the scrubbed list has;
354
+ # bundler's own additions (absent from the pre-bundler value) stay
355
+ # dropped, and duplicates collapse to first occurrence.
356
+ def restore_dropped_rubylib_entries(env, original)
357
+ before = original["RUBYLIB"].to_s.split(File::PATH_SEPARATOR)
358
+ return if before.empty?
359
+
360
+ after = env["RUBYLIB"].to_s.split(File::PATH_SEPARATOR)
361
+ env["RUBYLIB"] = (before | after).join(File::PATH_SEPARATOR)
362
+ end
363
+
346
364
  # Only a failing exit proves the command saw the broken file; a
347
365
  # pass is the anchoring or no-coverage case, and a timeout or setup
348
366
  # failure leaves visibility unknown. Both warn and name the file.
@@ -0,0 +1,34 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Execution
6
+ # The one guarded door for fiddle (#639). Ruby ships fiddle itself - a
7
+ # default gem on 3.x, a bundled gem from 4.0 - so a run never needs to
8
+ # compile it, and a box where it cannot load has a broken Ruby or a
9
+ # missing Gemfile line, not a reason to run mutants without the sandbox
10
+ # layers. Every load goes through here and a failure REFUSES with the
11
+ # fixes, instead of a bundler-time source build dying on missing ffi.h.
12
+ module FiddleLoader
13
+ MESSAGE = "saboteur cannot load fiddle, which Ruby normally includes " \
14
+ "(a default gem on Ruby 3.x, a bundled gem on Ruby 4.0+), " \
15
+ "and refuses to run without its sandbox layers. Fix one of: " \
16
+ "reinstall Ruby so its shipped fiddle returns; " \
17
+ "run `gem install fiddle`; " \
18
+ "add `gem \"fiddle\"` to the Gemfile (required on Ruby 4.0+); " \
19
+ "or install the libffi headers (libffi-dev on Debian/Ubuntu, " \
20
+ "libffi-devel on Fedora/RHEL) and reinstall fiddle."
21
+
22
+ # Loads fiddle (plus fiddle/import for the one caller using its
23
+ # mixin). require is idempotent, so every touchpoint asks again for
24
+ # free. A LoadError means the sandbox layers cannot work: REFUSE -
25
+ # the error propagates, it is never downgraded to a warning.
26
+ def self.require_fiddle!(with_import: false)
27
+ require "fiddle"
28
+ require "fiddle/import" if with_import
29
+ rescue LoadError
30
+ raise Error, MESSAGE
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,7 +1,7 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
- require "fiddle"
4
+ require_relative "fiddle_loader"
5
5
 
6
6
  module Saboteur
7
7
  module Execution
@@ -33,6 +33,7 @@ module Saboteur
33
33
  # usable prctl (macOS) the environment marker still names exec'd
34
34
  # descendants and the tree still tracks the rest.
35
35
  def write_comm(marker)
36
+ FiddleLoader.require_fiddle!
36
37
  prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
37
38
  [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP] + ([Fiddle::TYPE_LONG] * 3),
38
39
  Fiddle::TYPE_INT)
@@ -1,7 +1,7 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
- require "fiddle"
4
+ require_relative "fiddle_loader"
5
5
 
6
6
  module Saboteur
7
7
  module Execution
@@ -111,14 +111,18 @@ module Saboteur
111
111
  return @functions if defined?(@functions)
112
112
 
113
113
  @functions = begin
114
- require "fiddle"
114
+ FiddleLoader.require_fiddle!
115
115
  library = Fiddle.dlopen("/usr/lib/libproc.dylib")
116
116
  [Fiddle::Function.new(library["proc_listallpids"], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
117
117
  Fiddle::TYPE_INT),
118
118
  Fiddle::Function.new(library["proc_pidinfo"],
119
119
  [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG,
120
120
  Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)]
121
- rescue LoadError, Fiddle::DLError
121
+ # Error first: the loader's refusal (#639) must match even when
122
+ # Fiddle is undefined (the DLError constant would not resolve).
123
+ # The process table is not a sandbox layer, so it keeps
124
+ # degrading to the next backend.
125
+ rescue Error, LoadError, Fiddle::DLError
122
126
  nil
123
127
  end
124
128
  end
@@ -191,6 +191,13 @@ module Saboteur
191
191
  source = subject.file_source.dup.force_encoding(Encoding::UTF_8)
192
192
  spliced = source.byteslice(0, subject.start_byte) + text + source.byteslice(subject.end_byte..)
193
193
  silencing_warnings { TOPLEVEL_BINDING.eval(spliced, subject.file, 1) }
194
+ # The eval just loaded the file for this run: a later lazy require
195
+ # of it (dry-rb load_extensions-style) must be a no-op, or its
196
+ # re-assignment of the file's constants fires "already initialized
197
+ # constant" - fatal in --warnings suites with a raising Warning
198
+ # gate, which wrongly reports every mutant as an error (#632).
199
+ loaded = File.expand_path(subject.file)
200
+ $LOADED_FEATURES << loaded unless $LOADED_FEATURES.include?(loaded)
194
201
  end
195
202
 
196
203
  def eval_in_owner(owner, subject, text, extra_lines)
@@ -1,8 +1,7 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
- require "fiddle"
5
- require "fiddle/import"
4
+ require_relative "fiddle_loader"
6
5
 
7
6
  module Saboteur
8
7
  module Execution
@@ -57,6 +56,13 @@ module Saboteur
57
56
  return @abi_version if defined?(@abi_version)
58
57
  return @abi_version = 0 unless linux?
59
58
 
59
+ # Refuses when fiddle cannot load (#639): the sandbox is mandatory,
60
+ # so this error must reach the run, not the DLError degrade below.
61
+ FiddleLoader.require_fiddle!(with_import: true)
62
+ @abi_version = probed_abi_version
63
+ end
64
+
65
+ def probed_abi_version
60
66
  # Build the errno reader BEFORE the probe: its lazy dlopen/dlsym run
61
67
  # libc calls that can set errno, so building it between the probe and
62
68
  # the read can replace the real answer with a stale 0.
@@ -66,11 +72,11 @@ module Saboteur
66
72
  # the kernel's real answer - the read was clobbered between the call
67
73
  # and here. Probe once more, now with every Fiddle function warm.
68
74
  version, errno = probe_once if !version.positive? && errno.zero?
69
- @abi_version = probe_abi(version, errno)
75
+ probe_abi(version, errno)
70
76
  rescue Fiddle::DLError
71
77
  # No usable libc (an unusual musl/ldso layout): the sandbox cannot
72
78
  # be built here, so degrade to the unsandboxed note, not a crash.
73
- @abi_version = 0
79
+ 0
74
80
  end
75
81
 
76
82
  # A positive answer is the ABI. ENOSYS/EOPNOTSUPP mean the kernel has
@@ -1,7 +1,7 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
- require "fiddle"
4
+ require_relative "fiddle_loader"
5
5
 
6
6
  module Saboteur
7
7
  module Execution
@@ -50,9 +50,16 @@ module Saboteur
50
50
  return @probe if defined?(@probe)
51
51
  return @probe = :unsupported_platform unless darwin?
52
52
 
53
- @probe = run_probe
53
+ # Refuses when fiddle cannot load (#639), before the DLError
54
+ # degrade below can turn it into a silent :unavailable.
55
+ FiddleLoader.require_fiddle!
56
+ @probe = probed
57
+ end
58
+
59
+ def probed
60
+ run_probe
54
61
  rescue Fiddle::DLError
55
- @probe = :unavailable
62
+ :unavailable
56
63
  end
57
64
 
58
65
  def available?
@@ -5,6 +5,7 @@ require "securerandom"
5
5
 
6
6
  require_relative "process_tree"
7
7
  require_relative "protocol"
8
+ require_relative "fiddle_loader"
8
9
 
9
10
  module Saboteur
10
11
  module Execution
@@ -457,7 +458,7 @@ module Saboteur
457
458
  def become_subreaper
458
459
  return unless RUBY_PLATFORM.include?("linux")
459
460
 
460
- require "fiddle"
461
+ FiddleLoader.require_fiddle!
461
462
  prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
462
463
  [Fiddle::TYPE_INT, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
463
464
  prctl.call(PR_SET_CHILD_SUBREAPER, 1)