saboteur 0.7.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +772 -0
  3. data/README.md +221 -1072
  4. data/lib/saboteur/attribute_finder.rb +113 -0
  5. data/lib/saboteur/cli/exit_status.rb +32 -1
  6. data/lib/saboteur/cli/help_options.rb +2 -1
  7. data/lib/saboteur/cli/history_command.rb +135 -0
  8. data/lib/saboteur/cli/presets.rb +47 -3
  9. data/lib/saboteur/cli/run_budget_options.rb +3 -1
  10. data/lib/saboteur/cli/selection_options.rb +31 -0
  11. data/lib/saboteur/cli/shard_options.rb +17 -0
  12. data/lib/saboteur/cli/source_set.rb +35 -0
  13. data/lib/saboteur/cli/survivors_command.rb +157 -0
  14. data/lib/saboteur/cli.rb +74 -55
  15. data/lib/saboteur/config.rb +102 -6
  16. data/lib/saboteur/config_file.rb +19 -7
  17. data/lib/saboteur/constant_reads.rb +182 -0
  18. data/lib/saboteur/constant_subject.rb +70 -0
  19. data/lib/saboteur/constant_subject_finder.rb +281 -0
  20. data/lib/saboteur/coverage_reads.rb +29 -0
  21. data/lib/saboteur/custom_map.rb +12 -1
  22. data/lib/saboteur/dirty_tree_warning.rb +130 -0
  23. data/lib/saboteur/doctor.rb +13 -0
  24. data/lib/saboteur/engine/baseline_guard.rb +135 -21
  25. data/lib/saboteur/engine/boot.rb +11 -3
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  27. data/lib/saboteur/engine/candidate_selection.rb +20 -0
  28. data/lib/saboteur/engine/candidates.rb +43 -3
  29. data/lib/saboteur/engine/coverage_store.rb +58 -14
  30. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  31. data/lib/saboteur/engine/diagnostics.rb +19 -1
  32. data/lib/saboteur/engine/distribution.rb +10 -0
  33. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  34. data/lib/saboteur/engine/gated_run.rb +37 -0
  35. data/lib/saboteur/engine/load_coverage.rb +63 -0
  36. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  37. data/lib/saboteur/engine/pipelines.rb +115 -0
  38. data/lib/saboteur/engine/result_cache.rb +65 -8
  39. data/lib/saboteur/engine/run_budget.rb +32 -2
  40. data/lib/saboteur/engine/sandbox_policy.rb +59 -3
  41. data/lib/saboteur/engine/scheduler.rb +131 -17
  42. data/lib/saboteur/engine/selection_announcement.rb +59 -0
  43. data/lib/saboteur/engine/shard.rb +69 -0
  44. data/lib/saboteur/engine/subject_selection.rb +380 -21
  45. data/lib/saboteur/engine/test_guess.rb +407 -27
  46. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  47. data/lib/saboteur/engine.rb +103 -129
  48. data/lib/saboteur/execution/external_runner.rb +489 -0
  49. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  50. data/lib/saboteur/execution/marker_sweep.rb +80 -0
  51. data/lib/saboteur/execution/peak_memory.rb +28 -0
  52. data/lib/saboteur/execution/process_table.rb +19 -3
  53. data/lib/saboteur/execution/process_tree.rb +65 -5
  54. data/lib/saboteur/execution/reevaluator.rb +75 -12
  55. data/lib/saboteur/execution/runner.rb +46 -7
  56. data/lib/saboteur/execution/sandbox.rb +25 -6
  57. data/lib/saboteur/execution/seatbelt.rb +233 -0
  58. data/lib/saboteur/execution/supervisor.rb +29 -11
  59. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  60. data/lib/saboteur/execution/worker_pool.rb +23 -1
  61. data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -0
  62. data/lib/saboteur/gates.rb +43 -2
  63. data/lib/saboteur/help.rb +60 -9
  64. data/lib/saboteur/ignore_list.rb +29 -6
  65. data/lib/saboteur/integration/rspec.rb +223 -5
  66. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  67. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  68. data/lib/saboteur/mutation_source/builtin.rb +28 -5
  69. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  70. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  71. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  72. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  73. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  74. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  75. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  76. data/lib/saboteur/mutation_source/hazard_guard.rb +189 -0
  77. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  79. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  80. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  81. data/lib/saboteur/mutation_source/operator_registry.rb +5 -2
  82. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  83. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  84. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  85. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  86. data/lib/saboteur/preview.rb +125 -12
  87. data/lib/saboteur/refinement_activations.rb +60 -0
  88. data/lib/saboteur/reporter/agent.rb +9 -1
  89. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  90. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  91. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  92. data/lib/saboteur/reporter/findings.rb +5 -2
  93. data/lib/saboteur/reporter/history_policy.rb +11 -0
  94. data/lib/saboteur/reporter/history_reader.rb +47 -0
  95. data/lib/saboteur/reporter/html.rb +26 -4
  96. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  97. data/lib/saboteur/reporter/stryker.rb +27 -3
  98. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  99. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  100. data/lib/saboteur/reporter/terminal.rb +56 -8
  101. data/lib/saboteur/result.rb +19 -3
  102. data/lib/saboteur/selection/coverage_map.rb +120 -10
  103. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  104. data/lib/saboteur/selection/external_test_map.rb +27 -0
  105. data/lib/saboteur/selection/git_diff.rb +93 -11
  106. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  107. data/lib/saboteur/selection/recorder.rb +22 -4
  108. data/lib/saboteur/selection/source_scope.rb +22 -0
  109. data/lib/saboteur/subject.rb +42 -7
  110. data/lib/saboteur/subject_finder.rb +173 -29
  111. data/lib/saboteur/version.rb +1 -1
  112. data/lib/saboteur/working_tree.rb +38 -15
  113. data/lib/saboteur.rb +14 -0
  114. metadata +39 -16
@@ -0,0 +1,130 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # The warning shown before a run on a tree with uncommitted
6
+ # changes (#309, #316); the headline says uncommitted or untracked, so a
7
+ # tree of only scratch files is not read as edits (#604). Mutants run your code and can change the tree, so the
8
+ # warning stays; it splits the raw count so a tree full of notes or trial
9
+ # setup files does not read like application edits about to be mutated.
10
+ #
11
+ # Each change lands in exactly one group, so the groups add up to the
12
+ # count: saboteur setup files first (the config file the run reads, a
13
+ # trial Gemfile and its lock), then the mutation scope (a file the
14
+ # configured include and exclude select, the way Config#included_files
15
+ # does), then the rest. A path the patterns match also counts, so a
16
+ # deleted source is still inside the scope. The scope is the configured
17
+ # one: --since, --subject and source operands narrow a run further, after
18
+ # this warning.
19
+ class DirtyTreeWarning
20
+ SHOWN_PATHS = 5
21
+ MATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
22
+ ADVICE = "Mutants run your code and could alter or delete files; " \
23
+ "run on a clean, committed tree or a CI checkout."
24
+
25
+ # The warning text for the run's working tree, or nil on a clean or
26
+ # unknown tree. The root is physical (realpath), like the paths
27
+ # Config#included_files returns.
28
+ def self.for(config, config_path:, gemfile: ENV.fetch("BUNDLE_GEMFILE", nil))
29
+ state = WorkingTree.new(config.root, ignored: config.output_paths).state
30
+ root = File.realpath(config.root)
31
+ new(state, included: relative(config.included_files, root), include: config["include"],
32
+ exclude: config["exclude"], root: root,
33
+ setup_files: setup_files(root, config_path: config_path, gemfile: gemfile)).to_s
34
+ end
35
+
36
+ # Files a trial of saboteur adds, relative to the root: the config file
37
+ # the run reads and, when BUNDLE_GEMFILE names a Gemfile other than the
38
+ # root's own, that Gemfile and its lock. Each file is compared by its
39
+ # physical path, so a /var spelling of a /private/var root still
40
+ # matches (#503).
41
+ def self.setup_files(root, config_path:, gemfile:)
42
+ files = [physical(File.expand_path(config_path || Config::FILE, root))]
43
+ trial = gemfile && physical(File.expand_path(gemfile, root))
44
+ files.push(trial, lockfile_for(trial)) if trial && trial != File.join(root, "Gemfile")
45
+ relative(files, root)
46
+ end
47
+
48
+ # Bundler's own rule, the same one preflight's execution inputs check
49
+ # against Bundler.default_lockfile: a gems.rb trial locks to gems.locked
50
+ # next to it; every other name gets ".lock" appended.
51
+ def self.lockfile_for(gemfile)
52
+ return File.join(File.dirname(gemfile), "gems.locked") if File.basename(gemfile) == "gems.rb"
53
+
54
+ "#{gemfile}.lock"
55
+ end
56
+
57
+ # git status paths are relative to the root, so the patterns are too:
58
+ # Dir.glob accepts a leading ./ or an absolute path under the root and
59
+ # File.fnmatch? does not (#503). A pattern outside the root stays
60
+ # absolute and matches no git path.
61
+ def self.relative_patterns(patterns, root)
62
+ patterns.map { |pattern| File.expand_path(pattern, root).delete_prefix(File.join(root, "")) }
63
+ end
64
+
65
+ # The path with its directory resolved, so a file that does not exist
66
+ # yet still gets the physical spelling of where it would be.
67
+ def self.physical(path)
68
+ File.join(File.realpath(File.dirname(path)), File.basename(path))
69
+ rescue SystemCallError
70
+ path
71
+ end
72
+
73
+ # A file outside the root keeps its absolute path, which no
74
+ # root-relative git path can equal.
75
+ def self.relative(files, root)
76
+ prefix = File.join(root, "")
77
+ files.map { |file| file.delete_prefix(prefix) }
78
+ end
79
+
80
+ private_class_method :physical, :relative
81
+
82
+ def initialize(state, included:, include:, exclude:, setup_files:, root:)
83
+ @state = state
84
+ @included = included.to_set
85
+ @include = self.class.relative_patterns(include, root)
86
+ @exclude = self.class.relative_patterns(exclude, root)
87
+ @setup_files = setup_files
88
+ end
89
+
90
+ def to_s
91
+ return unless @state.dirty
92
+
93
+ setup, rest = @state.changes.partition { |change| @setup_files.include?(change.path) }
94
+ scope, outside = rest.partition { |change| in_scope?(change.path) }
95
+ [summary(setup, scope, outside), named("inside the mutation scope", scope), named("setup files", setup),
96
+ " #{ADVICE}"].compact.join("\n")
97
+ end
98
+
99
+ private
100
+
101
+ def summary(setup, scope, outside)
102
+ groups = [scope.empty? ? "none inside the mutation scope" : counted("inside the mutation scope", scope),
103
+ counted("outside it", outside)]
104
+ groups << counted("saboteur setup file(s)", setup) unless setup.empty?
105
+ "saboteur: warning: #{@state.changed_files} uncommitted or untracked change(s) in the working tree: " \
106
+ "#{groups.join(', ')}."
107
+ end
108
+
109
+ def counted(label, changes)
110
+ tracked = changes.count(&:tracked)
111
+ "#{changes.size} #{label} (#{tracked} tracked, #{changes.size - tracked} untracked)"
112
+ end
113
+
114
+ def named(label, changes)
115
+ return if changes.empty?
116
+
117
+ paths = changes.first(SHOWN_PATHS).map(&:path).join(", ")
118
+ more = changes.size - SHOWN_PATHS
119
+ " #{label}: #{paths}#{" and #{more} more" if more.positive?}"
120
+ end
121
+
122
+ def in_scope?(path)
123
+ @included.include?(path) || (path.end_with?(".rb") && matches?(@include, path) && !matches?(@exclude, path))
124
+ end
125
+
126
+ def matches?(patterns, path)
127
+ patterns.any? { |pattern| File.fnmatch?(pattern, path, MATCH_FLAGS) }
128
+ end
129
+ end
130
+ end
@@ -98,6 +98,19 @@ module Saboteur
98
98
  else
99
99
  @out.puts "Sandbox: #{Engine::SandboxPolicy.sandbox_gap(abi)}; " \
100
100
  "children would run with the hazard guard and canary only"
101
+ if RUBY_PLATFORM.match?(/darwin/i) && @config["sandbox"] == "seatbelt"
102
+ @out.puts %(Seatbelt (opt-in, #436): probe #{Execution::Seatbelt.probe} - capability probe only, ) \
103
+ "not verified isolation"
104
+ elsif RUBY_PLATFORM.match?(/darwin/i)
105
+ # Point at the way OUT of the degraded state, like the run
106
+ # warning does (#667): the opt-in Seatbelt sandbox, or silence.
107
+ @out.puts "macOS: mutants run unsandboxed (Landlock is Linux-only) - a container contains " \
108
+ "filesystem writes outside the project"
109
+ @out.puts %(macOS: opt into the sandbox with c.sandbox = "seatbelt" ) \
110
+ "(docs/guides/macos-seatbelt-sandbox.md), or set c.sandbox = false to silence this"
111
+ @out.puts "Network: never restricted on any platform, and a plain container keeps outbound " \
112
+ "access - deny or stub the network when a mutant could reach real APIs"
113
+ end
101
114
  end
102
115
  end
103
116
 
@@ -4,6 +4,7 @@
4
4
  require "json"
5
5
  require "fileutils"
6
6
  require_relative "../text"
7
+ require_relative "../frameworks/sqlite_memory_database"
7
8
 
8
9
  module Saboteur
9
10
  class Engine
@@ -23,7 +24,7 @@ module Saboteur
23
24
  # coverage build when the cache is cold, so the first timed pass can
24
25
  # price the rest (#260).
25
26
  def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, full_passes: 2, between_passes: nil,
26
- timeout_cap: nil)
27
+ timeout_cap: nil, run_started: nil)
27
28
  @config = config
28
29
  @integration = integration
29
30
  @hooks = hooks
@@ -32,6 +33,7 @@ module Saboteur
32
33
  @full_passes = full_passes
33
34
  @between_passes = between_passes
34
35
  @timeout_cap = timeout_cap
36
+ @run_started = run_started
35
37
  @completed_passes = 0
36
38
  end
37
39
 
@@ -50,10 +52,7 @@ module Saboteur
50
52
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
53
  data = check(number)
52
54
  announce_first_pass_cost(number, started)
53
- unless data["passed"] && data["failed"].empty?
54
- refuse(number,
55
- "the suite failed: #{Array(data['failed']).join(', ')}")
56
- end
55
+ refuse_suite_failure(number, data) unless data["passed"] && data["failed"].empty?
57
56
  executed = data["executed"]
58
57
  refuse(number, "executed tests do not match the loaded suite") unless
59
58
  executed.sort == expected.sort
@@ -66,6 +65,16 @@ module Saboteur
66
65
 
67
66
  private
68
67
 
68
+ def refuse_failed_suite(number, data)
69
+ outside = data["failed"].empty?
70
+ reason = if outside
71
+ "the suite errored outside any example; see the log tail"
72
+ else
73
+ "the suite failed: #{data['failed'].join(', ')}"
74
+ end
75
+ refuse(number, with_hint(reason, data), tail: outside)
76
+ end
77
+
69
78
  # A spent --max-duration budget skips the remaining passes - including
70
79
  # the first, when boot alone already spent it (#245).
71
80
  def budget_spent?(_index)
@@ -85,16 +94,47 @@ module Saboteur
85
94
  end
86
95
 
87
96
  # Once the first pass is timed, the total cost stops being a guess:
88
- # the remaining full passes cost roughly the same each.
97
+ # the remaining full passes cost roughly the same each. full_passes
98
+ # includes the cold-cache coverage build (#260), so name what actually
99
+ # remains: with baseline_passes 1 the one remaining pass is the coverage
100
+ # build, not another baseline check (#465).
89
101
  def announce_first_pass_cost(number, started)
90
102
  return unless number == 1
91
103
 
92
- remaining = @full_passes - number
93
- return if remaining.zero?
94
-
104
+ coverage_pending = @full_passes > @config["baseline_passes"]
105
+ remaining = @full_passes - number - (coverage_pending ? 1 : 0)
95
106
  elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round
96
- @diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * remaining} s left for " \
97
- "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'} at that rate")
107
+ warn_budget_projection(elapsed, remaining + (coverage_pending ? 1 : 0))
108
+ return if remaining.zero? && !coverage_pending
109
+
110
+ work = []
111
+ work << "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'}" if remaining.positive?
112
+ work << "the coverage map build" if coverage_pending
113
+ total = remaining + (coverage_pending ? 1 : 0)
114
+ @diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
115
+ "#{work.join(' and ')} at that rate")
116
+ end
117
+
118
+ # The first timed pass also prices the run against --max-duration:
119
+ # boot and this pass are spent, the remaining passes and a cold
120
+ # coverage build cost about that much again at that rate, and only
121
+ # what is left feeds mutants. Warn while a Ctrl-C is still cheap -
122
+ # the refusal only fires once the coverage build is unaffordable
123
+ # outright (#637).
124
+ def warn_budget_projection(pass_elapsed, remaining_passes)
125
+ budget = @config["max_duration"]
126
+ return unless budget && @run_started
127
+
128
+ spent = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started
129
+ projected = spent + (pass_elapsed * remaining_passes)
130
+ mutants_get = budget - projected
131
+ return if mutants_get >= pass_elapsed
132
+
133
+ verdict = mutants_get <= 0 ? "exceeds" : "barely fits"
134
+ @diagnostics.warn("projected baseline+coverage ~#{projected.round}s #{verdict} " \
135
+ "--max-duration #{budget.round}s; mutants would get " \
136
+ "~#{format('%g', [mutants_get, 0].max.round(1))}s. Raise --max-duration, pass fewer " \
137
+ "--baseline-passes, or narrow the scope")
98
138
  end
99
139
 
100
140
  # An admitted pass still gets only the time left in the run budget:
@@ -117,6 +157,24 @@ module Saboteur
117
157
  "phase timeout)"
118
158
  end
119
159
 
160
+ # A containment limit stopping a healthy suite means the limit, not
161
+ # the suite: name the knob and its current value, like the sqlite
162
+ # :memory: hint (#387), so the user is not sent debugging a green
163
+ # suite (#459).
164
+ def stopped_reason(result)
165
+ reason = "the suite stopped: #{result.stopped_by} (#{result.detail})"
166
+ case result.stopped_by
167
+ when :processes
168
+ "#{reason}. If this suite forks heavily by design, raise the limit with " \
169
+ "c.max_processes (currently #{@config['max_processes']})"
170
+ when :memory
171
+ "#{reason}. If this suite legitimately needs more memory, raise the limit with " \
172
+ "c.max_memory_mb (currently #{@config['max_memory_mb']} MB)"
173
+ else
174
+ reason
175
+ end
176
+ end
177
+
120
178
  def check(number)
121
179
  Execution::ForkReadiness.prepare_heap
122
180
  heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
@@ -126,14 +184,14 @@ module Saboteur
126
184
  record(writer, number)
127
185
  end
128
186
  refuse_budget_stop(number, admitted) if budget_stopped?(result, admitted)
129
- refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
187
+ refuse(number, stopped_reason(result)) if result.stopped_by
130
188
  # Before the payload checks: a child killed by a signal (the OOM
131
189
  # killer, say) writes nothing, and the signal is the explanation. A
132
190
  # child the supervisor itself killed after it had reported is not a
133
191
  # failure: its payload is checked below.
134
192
  refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
135
193
  refuse(number, "the child did not report a result") if result.payload.empty?
136
- refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
194
+ refuse(number, leftover_message(result), excerpt: false) if result.leftover_pids.any?
137
195
  parse_result(number, result.payload)
138
196
  end
139
197
 
@@ -151,7 +209,7 @@ module Saboteur
151
209
  def parse_result(number, payload)
152
210
  data = JSON.parse(payload)
153
211
  refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
154
- refuse(number, data["error"]) if data["error"]
212
+ refuse(number, with_hint(data["error"], data)) if data["error"]
155
213
  refuse(number, "the child reported invalid test results") unless valid_result?(data)
156
214
  data
157
215
  rescue JSON::ParserError
@@ -172,31 +230,87 @@ module Saboteur
172
230
  @hooks.run(:after_fork)
173
231
  Kernel.srand(@config["seed"])
174
232
  result = @integration.run_baseline(seed: @config["seed"])
175
- writer.write(JSON.generate(passed: result.passed, failed: result.failed_ids, executed: result.executed_ids))
233
+ payload = { passed: result.passed, failed: result.failed_ids, executed: result.executed_ids }
234
+ payload[:hint] = Frameworks::SqliteMemoryDatabase.fork_loss_hint unless result.passed
235
+ writer.write(JSON.generate(payload))
176
236
  rescue Exception => e # rubocop:disable Lint/RescueException
177
- writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
237
+ writer.write(JSON.generate(error: "#{e.class}: #{e.message}",
238
+ hint: Frameworks::SqliteMemoryDatabase.fork_loss_hint))
178
239
  ensure
179
240
  writer.close
180
241
  end
181
242
 
243
+ # A failed unchanged suite never runs mutants: warn first when the
244
+ # failure carries the Minitest signature (#626), then refuse.
245
+ def refuse_suite_failure(number, data)
246
+ warn_minitest_signature(number)
247
+ refuse_failed_suite(number, data)
248
+ end
249
+
250
+ # Minitest::Spec suites in *_spec.rb clothing load under the RSpec
251
+ # integration and then fail the baseline with NoMethodError for
252
+ # Minitest-only methods (#626): the refusal still reads "fix the
253
+ # suite", so warn what the signature actually points at. It stays a
254
+ # warning: an RSpec suite can require minitest for a helper and
255
+ # fail for its own reason, and a heuristic refusal would
256
+ # false-positive there.
257
+ def warn_minitest_signature(number)
258
+ lines = log_lines(number)
259
+ errors = lines.grep(/NoMethodError/)
260
+ return if errors.empty? || !spec_helper_requires_minitest?
261
+
262
+ symptom = (lines.find { |line| line.include?("undefined method") } || errors.first).strip
263
+ @diagnostics.warn("the baseline failure looks like a Minitest suite running under RSpec, not a " \
264
+ "broken RSpec suite: the log shows #{errors.size} " \
265
+ "NoMethodError#{'s' if errors.size > 1} (for example: #{symptom.slice(0, EXCERPT_WIDTH)}) " \
266
+ "and spec/spec_helper.rb requires minitest. saboteur is RSpec-only, so a " \
267
+ "Minitest suite cannot be mutation tested with it.")
268
+ end
269
+
270
+ # The framework check behind the warning: spec_helper is the
271
+ # suite's own declaration of what it runs on.
272
+ def spec_helper_requires_minitest?
273
+ File.read(File.join(@config.root, "spec", "spec_helper.rb")).match?(/^\s*require\s+["']minitest/)
274
+ rescue SystemCallError
275
+ false
276
+ end
277
+
278
+ # A child that recognises its own failure shape (an empty sqlite
279
+ # :memory: database after the fork, #387) says how to fix it.
280
+ def with_hint(reason, data)
281
+ data["hint"] ? "#{reason}. #{data['hint']}" : reason
282
+ end
283
+
182
284
  def log_path(number)
183
285
  File.join(@config.path("cache_dir"), "baseline-#{number}.log")
184
286
  end
185
287
 
186
- def refuse(number, reason)
288
+ # Leftover children are a pass-with-leaks verdict, not a suite
289
+ # failure: the message names the leaked processes (the supervisor's
290
+ # pre-kill notes) and skips the log excerpt, which holds only RSpec
291
+ # output and can never show them (#486).
292
+ def leftover_message(result)
293
+ notes = result.leftover_notes
294
+ detail = notes.empty? ? result.leftover_pids.join(", ") : notes.join(", ")
295
+ count = result.leftover_pids.size
296
+ "the suite passed but left #{count} child #{count == 1 ? 'process' : 'processes'} behind: #{detail}"
297
+ end
298
+
299
+ def refuse(number, reason, excerpt: true, tail: false)
187
300
  raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
188
- "#{failure_excerpt(number)}See the full log at #{log_path(number)}. Fix the suite or its " \
189
- "setup, then run saboteur again with the same seed. No mutants were started."
301
+ "#{failure_excerpt(number, tail: tail) if excerpt}See the full log at #{log_path(number)}. " \
302
+ "Fix the suite or its setup, then run saboteur again with the same seed. " \
303
+ "No mutants were started."
190
304
  end
191
305
 
192
306
  # The first lines of the failure, inline, so the cause is readable
193
307
  # without opening the log: RSpec's "Failures:" section when the log has
194
308
  # one, otherwise the last lines of the log.
195
- def failure_excerpt(number)
309
+ def failure_excerpt(number, tail: false)
196
310
  lines = log_lines(number)
197
311
  return "" if lines.empty?
198
312
 
199
- start = lines.index { |line| line.start_with?("Failures:") }
313
+ start = lines.index { |line| line.start_with?("Failures:") } unless tail
200
314
  shown = start ? lines[start, EXCERPT_LINES] : lines.last(EXCERPT_LINES)
201
315
  "The log says:\n#{shown.map { |line| " #{line.slice(0, EXCERPT_WIDTH)}" }.join("\n")}\n"
202
316
  end
@@ -10,6 +10,10 @@ module Saboteur
10
10
  # A test suite that (perhaps only under a mutant) starts a saboteur run
11
11
  # would otherwise run saboteur inside a saboteur child, recursively.
12
12
  def self.refuse_recursion!
13
+ if ENV[Execution::ExternalRunner::CHILD_MARKER]
14
+ raise Error, "refusing to start: this process runs inside a saboteur external test command " \
15
+ "(#{Execution::ExternalRunner::CHILD_MARKER} is set); a nested run could race its tree"
16
+ end
13
17
  return unless ENV[Execution::Supervisor::CHILD_ENV]
14
18
 
15
19
  raise Error, "refusing to start: this process is a saboteur child " \
@@ -62,7 +66,9 @@ module Saboteur
62
66
  @rails = rails = Frameworks::Rails.new(@config, @hooks) if @config["rails"]
63
67
  rails&.before_boot
64
68
  @hooks.run(:before_boot)
65
- add_spec_to_load_path if @config["rails"]
69
+ add_spec_to_load_path
70
+ # Helpers may configure RSpec before the integration loads specs.
71
+ Integration::RSpec.new(@config).require_framework if @config["integration"] == "rspec"
66
72
  @config["require"].each { |file| require File.expand_path(file, @root) }
67
73
  integration = Integration.fetch(@config["integration"]).new(@config)
68
74
  integration.setup
@@ -74,8 +80,10 @@ module Saboteur
74
80
  private
75
81
 
76
82
  # rspec puts spec/ on the load path, so a boot file under spec/ (a
77
- # dummy app's rails_helper, say) can require its siblings. Match
78
- # that, or the same require list fails to load.
83
+ # dummy app's rails_helper, or a non-Rails gem's spec_helper that
84
+ # requires its siblings) can require its siblings. Match that for
85
+ # every project - a non-Rails gem does not add spec/ itself (#615) -
86
+ # or the same require list fails to load.
79
87
  def add_spec_to_load_path
80
88
  path = File.join(@root, "spec")
81
89
  $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
@@ -42,11 +42,27 @@ module Saboteur
42
42
 
43
43
  def fingerprint(text, frozen)
44
44
  options = { frozen_string_literal: frozen }
45
- strip_positions(RubyVM::InstructionSequence.compile(text, nil, nil, 1, options).to_a)
45
+ iseq = warnings_silenced do
46
+ RubyVM::InstructionSequence.compile(text, "saboteur/bytecode-equivalence", nil, 1, options)
47
+ end
48
+ strip_positions(iseq.to_a)
46
49
  rescue SyntaxError, StandardError
47
50
  nil
48
51
  end
49
52
 
53
+ # Mutants compile one by one, and generated bodies (body_nil,
54
+ # statement_removal) trip Ruby's own void-context warnings, so a host
55
+ # suite running with -w gets one line per mutant flooding stderr
56
+ # (#592, #656). The check is internal: silence the compiler. A mutant
57
+ # that does not parse still fails the compile and runs.
58
+ def warnings_silenced
59
+ saved = $VERBOSE
60
+ $VERBOSE = nil
61
+ yield
62
+ ensure
63
+ $VERBOSE = saved
64
+ end
65
+
50
66
  # Structural equality that never coerces: an Integer is not a Float,
51
67
  # and +0.0 is not -0.0 (1.0 / +0.0 and 1.0 / -0.0 are different
52
68
  # infinities), which eql? alone would not tell apart.
@@ -22,6 +22,10 @@ module Saboteur
22
22
  reason = subject.skip_reason || (skip_reason_for(subject) if @map.tests_for(subject).empty?)
23
23
  if reason
24
24
  @skipped_subjects[subject] = reason
25
+ # In a scoped run every subject is part of the change under
26
+ # review, so a skip is said up front, not only in the end-of-run
27
+ # summary (#574).
28
+ @diagnostics.announce(skip_announcement(subject, reason)) if scope
25
29
  return @selected[subject] = []
26
30
  end
27
31
 
@@ -44,9 +48,25 @@ module Saboteur
44
48
 
45
49
  private
46
50
 
51
+ # A scoped no-coverage skip names the specs the run loaded: coverage
52
+ # living in a sibling spec file is otherwise invisible, and the false
53
+ # read is "untestable" when the truth is "not loaded" (#636). Names
54
+ # at most five, like the other selection notes.
55
+ def skip_announcement(subject, reason)
56
+ line = "#{subject.id}: skipped (#{reason}); its change is not verified by this run"
57
+ return line unless reason == "no coverage"
58
+
59
+ loaded = @map.tests.map { |id| id.split("[").first.delete_prefix("./") }.uniq.sort
60
+ more = loaded.size > 5 ? ", ..." : ""
61
+ names = loaded.empty? ? "none" : loaded.first(5).join(", ")
62
+ "#{line} - loaded specs: #{names}#{more}; " \
63
+ "other spec files may cover it, pass --test to load them"
64
+ end
65
+
47
66
  # Load-only code is not "no coverage": it ran, just never inside a
48
67
  # test, and no forked test can re-run it (see CoverageMap::RAN_AT_LOAD).
49
68
  def skip_reason_for(subject)
69
+ return Selection::CoverageMap::PRELOADED if @map.preloaded?(subject.file)
50
70
  return "no coverage" unless @map.ran_at_load?(subject.file, subject.coverage_lines)
51
71
 
52
72
  Selection::CoverageMap::RAN_AT_LOAD
@@ -10,6 +10,25 @@ module Saboteur
10
10
  # a subject into runnable candidates: applied, re-parsed, de-duplicated,
11
11
  # coarse first. Returns [[mutation, mutated_text]].
12
12
  class Candidates
13
+ # The generator for constant hash/array literal subjects (#306); the
14
+ # method operators walk a def body a constant does not have.
15
+ def self.build(config, diagnostics, hazard_guard:)
16
+ new(build_sources(config), diagnostics, hazard_guard: hazard_guard,
17
+ constant_source: build_constant_source(config))
18
+ end
19
+
20
+ # Constants are generated by the builtin operator family, so a
21
+ # config that drops "builtin" from c.sources drops constant mutation
22
+ # too: the configured sources are the boundary for every subject
23
+ # kind (#319). Custom sources never see constants.
24
+ def self.build_constant_source(config)
25
+ return nil unless config["sources"].include?("builtin")
26
+
27
+ MutationSource::ConstantLiterals.new(operators: config["operators"],
28
+ exclude_operators: config["exclude_operators"],
29
+ arid: config["arid"])
30
+ end
31
+
13
32
  def self.build_sources(config)
14
33
  root = File.realpath(config.root)
15
34
  config["sources"].map do |entry|
@@ -19,15 +38,26 @@ module Saboteur
19
38
  end
20
39
 
21
40
  file, const = entry.split(":", 2)
22
- raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName" unless const
41
+ unless const
42
+ if entry.include?("*")
43
+ raise ConfigError, "mutation source #{entry.inspect} looks like a source glob; " \
44
+ "did you mean c.include? c.sources registers custom operator sources " \
45
+ "(builtin or path.rb:ClassName)"
46
+ end
47
+ raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName"
48
+ end
23
49
 
24
50
  require File.expand_path(file, root)
25
51
  Object.const_get(const).new
26
52
  end
27
53
  end
28
54
 
29
- def initialize(sources, diagnostics, hazard_guard: nil, bytecode: BytecodeEquivalence.new)
55
+ # constant_source: the generator for ConstantSubject instances; nil
56
+ # leaves constants with no candidates (a safe empty list).
57
+ def initialize(sources, diagnostics, hazard_guard: nil, bytecode: BytecodeEquivalence.new,
58
+ constant_source: nil)
30
59
  @sources = sources
60
+ @constant_source = constant_source
31
61
  @diagnostics = diagnostics
32
62
  @hazard_guard = hazard_guard
33
63
  @bytecode = bytecode
@@ -40,7 +70,7 @@ module Saboteur
40
70
  end
41
71
 
42
72
  seen = Set[subject.source]
43
- list = @sources.flat_map { |source| proposals(source, subject) }.filter_map do |mutation|
73
+ list = sources_for(subject).flat_map { |source| proposals(source, subject) }.filter_map do |mutation|
44
74
  candidate(subject, mutation, seen)
45
75
  end
46
76
  list = suppress_inline(list, subject)
@@ -50,6 +80,15 @@ module Saboteur
50
80
 
51
81
  private
52
82
 
83
+ # Constant literals have their own generator; external custom sources
84
+ # implement the def-subject contract and never see constants (#306).
85
+ def sources_for(subject)
86
+ return @sources unless subject.is_a?(ConstantSubject)
87
+ return [] unless @constant_source
88
+
89
+ [@constant_source]
90
+ end
91
+
53
92
  def suppress_inline(list, subject)
54
93
  directives = InlineSuppression.new(subject.file, subject.file_source)
55
94
  list.reject do |mutation, _text|
@@ -95,6 +134,7 @@ module Saboteur
95
134
  seen << text
96
135
  unless parses?(text)
97
136
  @diagnostics.count_invalid_mutation
137
+ @diagnostics.warn("#{subject.id}: dropped #{mutation.operator} mutation (did not parse)")
98
138
  return record_drop(subject, mutation, "invalid_syntax")
99
139
  end
100
140