saboteur 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/README.md +199 -952
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +80 -4
  8. data/lib/saboteur/cli/help_options.rb +17 -0
  9. data/lib/saboteur/cli/history_command.rb +135 -0
  10. data/lib/saboteur/cli/presets.rb +40 -0
  11. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  12. data/lib/saboteur/cli/run_reporting.rb +74 -5
  13. data/lib/saboteur/cli/selection_options.rb +31 -0
  14. data/lib/saboteur/cli/shard_options.rb +17 -0
  15. data/lib/saboteur/cli/source_set.rb +53 -0
  16. data/lib/saboteur/cli/survivors_command.rb +157 -0
  17. data/lib/saboteur/cli.rb +136 -98
  18. data/lib/saboteur/config.rb +163 -15
  19. data/lib/saboteur/config_file.rb +8 -0
  20. data/lib/saboteur/constant_reads.rb +182 -0
  21. data/lib/saboteur/constant_subject.rb +70 -0
  22. data/lib/saboteur/constant_subject_finder.rb +281 -0
  23. data/lib/saboteur/coverage_reads.rb +29 -0
  24. data/lib/saboteur/crash_report.rb +154 -0
  25. data/lib/saboteur/custom_map.rb +3 -1
  26. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  27. data/lib/saboteur/doctor.rb +49 -1
  28. data/lib/saboteur/engine/baseline_guard.rb +136 -14
  29. data/lib/saboteur/engine/boot.rb +16 -1
  30. data/lib/saboteur/engine/canary.rb +41 -10
  31. data/lib/saboteur/engine/candidate_selection.rb +28 -1
  32. data/lib/saboteur/engine/candidates.rb +43 -3
  33. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  34. data/lib/saboteur/engine/coverage_store.rb +79 -15
  35. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  36. data/lib/saboteur/engine/diagnostics.rb +41 -2
  37. data/lib/saboteur/engine/diff_scope.rb +24 -5
  38. data/lib/saboteur/engine/distribution.rb +134 -8
  39. data/lib/saboteur/engine/early_stop_recording.rb +47 -0
  40. data/lib/saboteur/engine/gated_run.rb +35 -0
  41. data/lib/saboteur/engine/load_coverage.rb +45 -0
  42. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  43. data/lib/saboteur/engine/pipelines.rb +107 -0
  44. data/lib/saboteur/engine/result_cache.rb +67 -9
  45. data/lib/saboteur/engine/run_budget.rb +90 -0
  46. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  47. data/lib/saboteur/engine/sandbox_policy.rb +56 -11
  48. data/lib/saboteur/engine/scheduler.rb +222 -20
  49. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  50. data/lib/saboteur/engine/shard.rb +69 -0
  51. data/lib/saboteur/engine/subject_selection.rb +373 -19
  52. data/lib/saboteur/engine/suite_files.rb +3 -1
  53. data/lib/saboteur/engine/test_guess.rb +438 -25
  54. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  55. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  56. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  57. data/lib/saboteur/engine/verdict_check.rb +54 -18
  58. data/lib/saboteur/engine.rb +202 -158
  59. data/lib/saboteur/execution/external_runner.rb +471 -0
  60. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  61. data/lib/saboteur/execution/peak_memory.rb +28 -0
  62. data/lib/saboteur/execution/process_table.rb +12 -0
  63. data/lib/saboteur/execution/process_tree.rb +65 -5
  64. data/lib/saboteur/execution/protocol.rb +2 -1
  65. data/lib/saboteur/execution/reevaluator.rb +68 -12
  66. data/lib/saboteur/execution/runner.rb +225 -24
  67. data/lib/saboteur/execution/sandbox.rb +21 -3
  68. data/lib/saboteur/execution/seatbelt.rb +226 -0
  69. data/lib/saboteur/execution/supervisor.rb +45 -13
  70. data/lib/saboteur/execution/worker_pool.rb +110 -8
  71. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  72. data/lib/saboteur/gates.rb +43 -2
  73. data/lib/saboteur/help.rb +108 -13
  74. data/lib/saboteur/ignore_list.rb +40 -7
  75. data/lib/saboteur/install.rb +4 -3
  76. data/lib/saboteur/integration/base.rb +7 -2
  77. data/lib/saboteur/integration/rspec.rb +175 -9
  78. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  79. data/lib/saboteur/mutation_source/builtin.rb +44 -14
  80. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  81. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  82. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  83. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  84. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  85. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  86. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  87. data/lib/saboteur/mutation_source/hazard_guard.rb +129 -3
  88. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  89. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  90. data/lib/saboteur/mutation_source/method_swap.rb +193 -0
  91. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  92. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  93. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  94. data/lib/saboteur/mutation_source/operator_examples.rb +6 -1
  95. data/lib/saboteur/mutation_source/operator_registry.rb +7 -2
  96. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  97. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  98. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  99. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  100. data/lib/saboteur/preview.rb +122 -13
  101. data/lib/saboteur/refinement_activations.rb +60 -0
  102. data/lib/saboteur/reporter/agent.rb +9 -1
  103. data/lib/saboteur/reporter/agent_commands.rb +47 -15
  104. data/lib/saboteur/reporter/agent_incomplete.rb +38 -2
  105. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  106. data/lib/saboteur/reporter/findings.rb +5 -2
  107. data/lib/saboteur/reporter/history_policy.rb +11 -0
  108. data/lib/saboteur/reporter/history_reader.rb +47 -0
  109. data/lib/saboteur/reporter/html.rb +26 -4
  110. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  111. data/lib/saboteur/reporter/next_steps.rb +14 -4
  112. data/lib/saboteur/reporter/score_history.rb +5 -1
  113. data/lib/saboteur/reporter/stryker.rb +40 -7
  114. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  115. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  116. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  117. data/lib/saboteur/reporter/terminal.rb +74 -17
  118. data/lib/saboteur/result.rb +38 -7
  119. data/lib/saboteur/selection/coverage_map.rb +107 -11
  120. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  121. data/lib/saboteur/selection/external_test_map.rb +27 -0
  122. data/lib/saboteur/selection/git_diff.rb +105 -13
  123. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  124. data/lib/saboteur/selection/recorder.rb +22 -4
  125. data/lib/saboteur/selection/source_scope.rb +22 -0
  126. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  127. data/lib/saboteur/subject.rb +53 -7
  128. data/lib/saboteur/subject_finder.rb +173 -29
  129. data/lib/saboteur/text.rb +42 -0
  130. data/lib/saboteur/version.rb +1 -1
  131. data/lib/saboteur/working_tree.rb +38 -15
  132. data/lib/saboteur.rb +29 -1
  133. metadata +50 -2
@@ -3,6 +3,8 @@
3
3
 
4
4
  require "coverage"
5
5
  require_relative "baseline_health"
6
+ require_relative "engine/sandbox_policy"
7
+ require_relative "execution/sandbox"
6
8
 
7
9
  module Saboteur
8
10
  # Reports the current environment, never mutations. The two unchanged suite
@@ -27,7 +29,8 @@ module Saboteur
27
29
  @out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
28
30
  check_database_hosts
29
31
  @out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
30
- check_coverage
32
+ verify_coverage
33
+ check_sandbox
31
34
  check_suite(integration, hooks)
32
35
  true
33
36
  end
@@ -63,15 +66,60 @@ module Saboteur
63
66
  "doctor supports the rspec integration; set integration: rspec"
64
67
  end
65
68
 
69
+ # Printed once; verified twice. Boot must not wake coverage up, so the
70
+ # post-boot check re-reads the state without printing a second line.
66
71
  def check_coverage
67
72
  state = Coverage.state
68
73
  @out.puts "Coverage: #{state}"
74
+ verify_coverage(state)
75
+ end
76
+
77
+ def verify_coverage(state = Coverage.state)
69
78
  return if Coverage.supported?(:lines) && Coverage.supported?(:methods) && state == :suspended
70
79
 
71
80
  raise Error, "Coverage must be suspended with line and method support before boot; use the saboteur " \
72
81
  "executable, disable SimpleCov and remove other Coverage.start calls"
73
82
  end
74
83
 
84
+ # A kernel capability probe, not verified isolation: what the kernel
85
+ # would let mutant children do, and what the run does with that - the
86
+ # engine's sandbox policy owns the refusal or degradation itself.
87
+ def check_sandbox
88
+ if @config["sandbox"] == false
89
+ @out.puts "Sandbox: off (c.sandbox = false)"
90
+ return
91
+ end
92
+ abi = sandbox_abi
93
+ if abi >= 3
94
+ @out.puts "Sandbox: kernel Landlock ABI #{abi} - capability probe only, not verified isolation"
95
+ elsif @config["sandbox"] == "strict"
96
+ @out.puts "Sandbox: #{Engine::SandboxPolicy.sandbox_gap(abi)}; " \
97
+ 'c.sandbox = "strict" refuses to run - children never run degraded'
98
+ else
99
+ @out.puts "Sandbox: #{Engine::SandboxPolicy.sandbox_gap(abi)}; " \
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
+ @out.puts "macOS: mutants run unsandboxed (Landlock is Linux-only) - a container contains " \
106
+ "filesystem writes outside the project"
107
+ @out.puts "Network: never restricted on any platform, and a plain container keeps outbound " \
108
+ "access - deny or stub the network when a mutant could reach real APIs"
109
+ end
110
+ end
111
+ end
112
+
113
+ # The probe contract the engine's policy already keeps: an errored probe
114
+ # (EPERM under a seccomp policy, ...) is an actionable tool error, not a
115
+ # CLI internal error - doctor stays read-only.
116
+ def sandbox_abi
117
+ Execution::Sandbox.abi_version
118
+ rescue SystemCallError => e
119
+ raise Error, "sandbox is on but the Landlock probe failed (#{e.message}); a run would refuse the same way - " \
120
+ "set c.sandbox = false to check without the sandbox"
121
+ end
122
+
75
123
  # Match against all included source definitions, not the current diff or
76
124
  # selected tests. A limited run must not make a valid ignore look stale.
77
125
  def check_ignore_subjects
@@ -3,6 +3,8 @@
3
3
 
4
4
  require "json"
5
5
  require "fileutils"
6
+ require_relative "../text"
7
+ require_relative "../frameworks/sqlite_memory_database"
6
8
 
7
9
  module Saboteur
8
10
  class Engine
@@ -10,16 +12,28 @@ module Saboteur
10
12
  # or mutation source is used. This catches observed instability, not all
11
13
  # possible flaky behaviour.
12
14
  class BaselineGuard
15
+ # How many unchanged-suite checks actually completed and validated:
16
+ # the number of order comparisons a run can claim is one less.
17
+ attr_reader :completed_passes
18
+
13
19
  EXCERPT_LINES = 10
14
20
  EXCERPT_WIDTH = 200
15
21
  EXCERPT_SCAN_LINES = 5000
16
22
 
17
- def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
23
+ # full_passes: every full-suite pass the run will make, including the
24
+ # coverage build when the cache is cold, so the first timed pass can
25
+ # price the rest (#260).
26
+ def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, full_passes: 2, between_passes: nil,
27
+ timeout_cap: nil)
18
28
  @config = config
19
29
  @integration = integration
20
30
  @hooks = hooks
21
31
  @supervisor = supervisor
22
32
  @diagnostics = diagnostics
33
+ @full_passes = full_passes
34
+ @between_passes = between_passes
35
+ @timeout_cap = timeout_cap
36
+ @completed_passes = 0
23
37
  end
24
38
 
25
39
  def call
@@ -27,40 +41,126 @@ module Saboteur
27
41
  raise Error, "baseline has no tests. Check rspec_args and the suite filters before running saboteur again." if
28
42
  expected.empty?
29
43
 
30
- @diagnostics.inform("checking the unchanged suite twice (seed #{@config['seed']})")
44
+ passes = @config["baseline_passes"]
45
+ @diagnostics.inform(announce(passes))
31
46
  previous = nil
32
- 2.times do |index|
47
+ passes.times do |index|
48
+ break if budget_spent?(index)
49
+
33
50
  number = index + 1
51
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
52
  data = check(number)
53
+ announce_first_pass_cost(number, started)
35
54
  unless data["passed"] && data["failed"].empty?
36
55
  refuse(number,
37
- "the suite failed: #{Array(data['failed']).join(', ')}")
56
+ with_hint("the suite failed: #{Array(data['failed']).join(', ')}", data))
38
57
  end
39
58
  executed = data["executed"]
40
59
  refuse(number, "executed tests do not match the loaded suite") unless
41
60
  executed.sort == expected.sort
42
61
  refuse(number, "test order changed between baseline checks") if previous && executed != previous
43
62
  previous = executed
63
+ @completed_passes += 1
44
64
  end
45
65
  nil
46
66
  end
47
67
 
48
68
  private
49
69
 
70
+ # A spent --max-duration budget skips the remaining passes - including
71
+ # the first, when boot alone already spent it (#245).
72
+ def budget_spent?(_index)
73
+ !@between_passes.nil? && @between_passes.call
74
+ end
75
+
76
+ def announce(passes)
77
+ case passes
78
+ when 1
79
+ "checking the unchanged suite once (seed #{@config['seed']}); " \
80
+ "order stability between passes is not checked (baseline_passes: 1)"
81
+ when 2
82
+ "checking the unchanged suite twice (seed #{@config['seed']})"
83
+ else
84
+ "checking the unchanged suite #{passes} times (seed #{@config['seed']})"
85
+ end
86
+ end
87
+
88
+ # Once the first pass is timed, the total cost stops being a guess:
89
+ # the remaining full passes cost roughly the same each. full_passes
90
+ # includes the cold-cache coverage build (#260), so name what actually
91
+ # remains: with baseline_passes 1 the one remaining pass is the coverage
92
+ # build, not another baseline check (#465).
93
+ def announce_first_pass_cost(number, started)
94
+ return unless number == 1
95
+
96
+ coverage_pending = @full_passes > @config["baseline_passes"]
97
+ remaining = @full_passes - number - (coverage_pending ? 1 : 0)
98
+ return if remaining.zero? && !coverage_pending
99
+
100
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round
101
+ work = []
102
+ work << "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'}" if remaining.positive?
103
+ work << "the coverage map build" if coverage_pending
104
+ total = remaining + (coverage_pending ? 1 : 0)
105
+ @diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
106
+ "#{work.join(' and ')} at that rate")
107
+ end
108
+
109
+ # An admitted pass still gets only the time left in the run budget:
110
+ # without the cap it would overrun a spent --max-duration by its
111
+ # whole coverage_timeout.
112
+ def phase_timeout
113
+ @timeout_cap ? @timeout_cap.call(@config["coverage_timeout"]) : @config["coverage_timeout"]
114
+ end
115
+
116
+ # A pass that hit its deadline only because --max-duration shortened
117
+ # the admission below the configured phase timeout: the budget ended
118
+ # the run, the suite is not broken (#296).
119
+ def budget_stopped?(result, admitted)
120
+ result.stopped_by == :timeout && admitted < @config["coverage_timeout"]
121
+ end
122
+
123
+ def refuse_budget_stop(number, admitted)
124
+ raise BudgetPhaseTimeout, "the --max-duration budget stopped baseline check #{number} " \
125
+ "(admitted #{admitted.round(1)}s of the #{@config['coverage_timeout']}s " \
126
+ "phase timeout)"
127
+ end
128
+
129
+ # A containment limit stopping a healthy suite means the limit, not
130
+ # the suite: name the knob and its current value, like the sqlite
131
+ # :memory: hint (#387), so the user is not sent debugging a green
132
+ # suite (#459).
133
+ def stopped_reason(result)
134
+ reason = "the suite stopped: #{result.stopped_by} (#{result.detail})"
135
+ case result.stopped_by
136
+ when :processes
137
+ "#{reason}. If this suite forks heavily by design, raise the limit with " \
138
+ "c.max_processes (currently #{@config['max_processes']})"
139
+ when :memory
140
+ "#{reason}. If this suite legitimately needs more memory, raise the limit with " \
141
+ "c.max_memory_mb (currently #{@config['max_memory_mb']} MB)"
142
+ else
143
+ reason
144
+ end
145
+ end
146
+
50
147
  def check(number)
51
148
  Execution::ForkReadiness.prepare_heap
52
149
  heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
53
- result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
150
+ admitted = phase_timeout
151
+ result = @supervisor.run(timeout: admitted, on_tick: heartbeat.method(:tick),
152
+ label: "baseline child died during setup/run") do |writer|
54
153
  record(writer, number)
55
154
  end
56
- refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
155
+ refuse_budget_stop(number, admitted) if budget_stopped?(result, admitted)
156
+ refuse(number, stopped_reason(result)) if result.stopped_by
57
157
  # Before the payload checks: a child killed by a signal (the OOM
58
158
  # killer, say) writes nothing, and the signal is the explanation. A
59
159
  # child the supervisor itself killed after it had reported is not a
60
160
  # failure: its payload is checked below.
61
161
  refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
62
162
  refuse(number, "the child did not report a result") if result.payload.empty?
63
- refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
163
+ refuse(number, leftover_message(result), excerpt: false) if result.leftover_pids.any?
64
164
  parse_result(number, result.payload)
65
165
  end
66
166
 
@@ -78,7 +178,7 @@ module Saboteur
78
178
  def parse_result(number, payload)
79
179
  data = JSON.parse(payload)
80
180
  refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
81
- refuse(number, data["error"]) if data["error"]
181
+ refuse(number, with_hint(data["error"], data)) if data["error"]
82
182
  refuse(number, "the child reported invalid test results") unless valid_result?(data)
83
183
  data
84
184
  rescue JSON::ParserError
@@ -99,21 +199,42 @@ module Saboteur
99
199
  @hooks.run(:after_fork)
100
200
  Kernel.srand(@config["seed"])
101
201
  result = @integration.run_baseline(seed: @config["seed"])
102
- writer.write(JSON.generate(passed: result.passed, failed: result.failed_ids, executed: result.executed_ids))
202
+ payload = { passed: result.passed, failed: result.failed_ids, executed: result.executed_ids }
203
+ payload[:hint] = Frameworks::SqliteMemoryDatabase.fork_loss_hint unless result.passed
204
+ writer.write(JSON.generate(payload))
103
205
  rescue Exception => e # rubocop:disable Lint/RescueException
104
- writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
206
+ writer.write(JSON.generate(error: "#{e.class}: #{e.message}",
207
+ hint: Frameworks::SqliteMemoryDatabase.fork_loss_hint))
105
208
  ensure
106
209
  writer.close
107
210
  end
108
211
 
212
+ # A child that recognises its own failure shape (an empty sqlite
213
+ # :memory: database after the fork, #387) says how to fix it.
214
+ def with_hint(reason, data)
215
+ data["hint"] ? "#{reason}. #{data['hint']}" : reason
216
+ end
217
+
109
218
  def log_path(number)
110
219
  File.join(@config.path("cache_dir"), "baseline-#{number}.log")
111
220
  end
112
221
 
113
- def refuse(number, reason)
222
+ # Leftover children are a pass-with-leaks verdict, not a suite
223
+ # failure: the message names the leaked processes (the supervisor's
224
+ # pre-kill notes) and skips the log excerpt, which holds only RSpec
225
+ # output and can never show them (#486).
226
+ def leftover_message(result)
227
+ notes = result.leftover_notes
228
+ detail = notes.empty? ? result.leftover_pids.join(", ") : notes.join(", ")
229
+ count = result.leftover_pids.size
230
+ "the suite passed but left #{count} child #{count == 1 ? 'process' : 'processes'} behind: #{detail}"
231
+ end
232
+
233
+ def refuse(number, reason, excerpt: true)
114
234
  raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
115
- "#{failure_excerpt(number)}See the full log at #{log_path(number)}. Fix the suite or its " \
116
- "setup, then run saboteur again with the same seed. No mutants were started."
235
+ "#{failure_excerpt(number) if excerpt}See the full log at #{log_path(number)}. " \
236
+ "Fix the suite or its setup, then run saboteur again with the same seed. " \
237
+ "No mutants were started."
117
238
  end
118
239
 
119
240
  # The first lines of the failure, inline, so the cause is readable
@@ -129,7 +250,8 @@ module Saboteur
129
250
  end
130
251
 
131
252
  def log_lines(number)
132
- File.foreach(log_path(number), chomp: true).first(EXCERPT_SCAN_LINES).reject { |line| line.strip.empty? }
253
+ File.foreach(log_path(number), chomp: true).first(EXCERPT_SCAN_LINES)
254
+ .map { |line| Text.utf8(line) }.reject { |line| line.strip.empty? }
133
255
  rescue SystemCallError
134
256
  []
135
257
  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 " \
@@ -83,10 +87,21 @@ module Saboteur
83
87
 
84
88
  def load_hooks
85
89
  Saboteur.current_hooks = @hooks
86
- @config["hooks"].each { |file| load File.expand_path(file, @root) }
90
+ @config["hooks"].each { |file| load hook_path(file) }
87
91
  ensure
88
92
  Saboteur.current_hooks = nil
89
93
  end
94
+
95
+ # Kernel#load does not append .rb the way Kernel#require does, and
96
+ # doctor accepts the extensionless form (#257), so resolve it the same
97
+ # way here: the exact path first, then path.rb.
98
+ def hook_path(file)
99
+ path = File.expand_path(file, @root)
100
+ rb = "#{path}.rb"
101
+ return rb if !File.exist?(path) && File.extname(path).empty? && File.file?(rb)
102
+
103
+ path
104
+ end
90
105
  end
91
106
  end
92
107
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "open3"
5
+ require_relative "../text"
5
6
 
6
7
  module Saboteur
7
8
  class Engine
@@ -15,7 +16,9 @@ module Saboteur
15
16
  GIT_DIR = ".git"
16
17
 
17
18
  def initialize(root, cache_dir: ".saboteur")
18
- @root = root
19
+ # Retag losslessly: path bytes are identity here, and later
20
+ # File.join calls must not mix encodings.
21
+ @root = Text.utf8_identity(root.to_s)
19
22
  @cache_dir = cache_dir.to_s
20
23
  @snapshot = inventory
21
24
  end
@@ -29,16 +32,40 @@ module Saboteur
29
32
 
30
33
  of = " of #{subject.id}" if subject
31
34
  culprit = digest ? " The most recent mutant, #{digest}#{of}, is the likely cause." : ""
32
- raise Saboteur::CanaryTripped,
33
- "canary: #{missing.size} watched file(s) vanished from the working tree while mutants ran " \
34
- "(first: #{missing.first}).#{culprit} The canary watches tracked files and .git presence " \
35
- "and only detects the loss after the fact; it cannot restore anything. Recover from your " \
36
- "own backup, a fresh checkout, or a reclone - a deleted .git or uncommitted work is gone " \
37
- "unless you have another copy."
35
+ raise Saboteur::CanaryTripped, message(missing, culprit)
36
+ end
37
+
38
+ # The cheap per-mutant probe (#235): stats the snapshot paths only -
39
+ # no git, no glob - so it can run after every mutant.
40
+ def probe
41
+ @snapshot.reject { |rel| present?(rel) }
42
+ end
43
+
44
+ # Like check!, but for the per-mutant probe: the mutant whose run just
45
+ # ended is named, never the subject's most recent mutant.
46
+ def probe!(subject: nil, digest: nil)
47
+ missing = probe
48
+ return if missing.empty?
49
+
50
+ of = " of #{subject.id}" if subject
51
+ culprit = digest ? " The mutant that just ran, #{digest}#{of}, is the likely cause." : ""
52
+ raise Saboteur::CanaryTripped.new(message(missing, culprit), culprit_digest: digest)
38
53
  end
39
54
 
40
55
  private
41
56
 
57
+ def present?(rel)
58
+ File.exist?(File.join(@root, rel)) || File.symlink?(File.join(@root, rel))
59
+ end
60
+
61
+ def message(missing, culprit)
62
+ "canary: #{missing.size} watched file(s) vanished from the working tree while mutants ran " \
63
+ "(first: #{Text.utf8(missing.first)}).#{culprit} The canary watches tracked files and .git presence " \
64
+ "and only detects the loss after the fact; it cannot restore anything. Recover from your " \
65
+ "own backup, a fresh checkout, or a reclone - a deleted .git or uncommitted work is gone " \
66
+ "unless you have another copy."
67
+ end
68
+
42
69
  def inventory
43
70
  files = git? ? git_tracked_files : walk_files
44
71
  files << GIT_DIR if File.exist?(File.join(@root, GIT_DIR))
@@ -54,15 +81,19 @@ module Saboteur
54
81
  return walk_files unless status.success?
55
82
 
56
83
  # ls-files reads the index, which still names a deleted file; keep
57
- # only paths present in the worktree so a deletion shows up.
58
- out.split("\0").reject { |rel| cache_path?(rel) }
84
+ # only paths present in the worktree so a deletion shows up. Split
85
+ # the raw bytes first and retag each path losslessly: scrubbing a
86
+ # raw-byte name would make the File.exist? probe fail and drop the
87
+ # file from the watch, so its deletion would go unseen.
88
+ out.b.split("\0".b).map { |rel| Text.utf8_identity(rel) }
89
+ .reject { |rel| cache_path?(rel) }
59
90
  .select { |rel| File.exist?(File.join(@root, rel)) || File.symlink?(File.join(@root, rel)) }
60
91
  end
61
92
 
62
93
  def walk_files
63
94
  Dir.glob(File.join(@root, "**", "*"), File::FNM_DOTMATCH)
64
95
  .select { |path| File.file?(path) }
65
- .map { |path| path.delete_prefix("#{@root}/") }
96
+ .map { |path| Text.utf8_identity(path).delete_prefix("#{@root}/") }
66
97
  .reject { |rel| rel.start_with?("#{GIT_DIR}/") || cache_path?(rel) }
67
98
  end
68
99
 
@@ -6,7 +6,7 @@ module Saboteur
6
6
  # Selects a bounded list for each covered subject before workers fork.
7
7
  # Subject skips are not mutants and do not enter the mutation score.
8
8
  class CandidateSelection
9
- attr_reader :skipped_subjects, :selected
9
+ attr_reader :skipped_subjects, :selected, :unscoped_digests
10
10
 
11
11
  def initialize(candidates:, map:, limit:, diagnostics:)
12
12
  @candidates = candidates
@@ -15,16 +15,28 @@ module Saboteur
15
15
  @diagnostics = diagnostics
16
16
  @skipped_subjects = {}
17
17
  @selected = {}
18
+ @unscoped_digests = {}
18
19
  end
19
20
 
20
21
  def for(subject, scope: nil)
21
22
  reason = subject.skip_reason || (skip_reason_for(subject) if @map.tests_for(subject).empty?)
22
23
  if reason
23
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
24
29
  return @selected[subject] = []
25
30
  end
26
31
 
27
32
  list = @candidates.for(subject)
33
+ # The full pre-scope digests let IgnoreList#apply tell "mutation gone"
34
+ # from "mutation scoped out of this run" (#284).
35
+ if scope
36
+ @unscoped_digests[subject.id] = list.map do |mutation, _|
37
+ mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
38
+ end
39
+ end
28
40
  list = scope.restrict(subject, list) if scope
29
41
  omitted = list.size - @limit
30
42
  if omitted.positive?
@@ -36,6 +48,21 @@ module Saboteur
36
48
 
37
49
  private
38
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
+
39
66
  # Load-only code is not "no coverage": it ran, just never inside a
40
67
  # test, and no forked test can re-run it (see CoverageMap::RAN_AT_LOAD).
41
68
  def skip_reason_for(subject)
@@ -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
 
@@ -0,0 +1,29 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Owns the host process's Coverage state across a run: suspends after
7
+ # boot, and at the end resumes only its own suspension. A CLI process
8
+ # exits right after, so resume matters to hosts that embed the engine -
9
+ # a spec, or the dogfood's own coverage-map build (#254).
10
+ class CoverageGuard
11
+ def initialize(diagnostics)
12
+ @diagnostics = diagnostics
13
+ end
14
+
15
+ def suspend
16
+ @was_running = Coverage.state == :running
17
+ Coverage.suspend if @was_running
18
+ end
19
+
20
+ # Best-effort: a host that disturbed Coverage mid-run gets a warning,
21
+ # never a masked run outcome.
22
+ def resume
23
+ Coverage.resume if @was_running && Coverage.state == :suspended
24
+ rescue StandardError => e
25
+ @diagnostics.warn("coverage resume failed: #{e.class}: #{e.message}")
26
+ end
27
+ end
28
+ end
29
+ end