saboteur 0.3.0 → 0.5.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +280 -1
  3. data/README.md +635 -77
  4. data/exe/saboteur +6 -2
  5. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  6. data/lib/saboteur/agent_skill.rb +46 -0
  7. data/lib/saboteur/baseline/acceptance.rb +218 -0
  8. data/lib/saboteur/baseline/command.rb +80 -0
  9. data/lib/saboteur/baseline/comparison.rb +105 -0
  10. data/lib/saboteur/baseline/completeness.rb +53 -0
  11. data/lib/saboteur/baseline/evidence.rb +114 -0
  12. data/lib/saboteur/baseline/identity.rb +78 -0
  13. data/lib/saboteur/baseline/manifest.rb +164 -0
  14. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  15. data/lib/saboteur/baseline/workflow.rb +121 -0
  16. data/lib/saboteur/baseline_health.rb +1 -1
  17. data/lib/saboteur/cli/exit_status.rb +93 -0
  18. data/lib/saboteur/cli/run_reporting.rb +75 -0
  19. data/lib/saboteur/cli.rb +171 -63
  20. data/lib/saboteur/config.rb +101 -19
  21. data/lib/saboteur/config_file.rb +97 -0
  22. data/lib/saboteur/custom_map.rb +170 -0
  23. data/lib/saboteur/doctor.rb +57 -9
  24. data/lib/saboteur/engine/baseline_guard.rb +44 -5
  25. data/lib/saboteur/engine/boot.rb +9 -0
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  27. data/lib/saboteur/engine/canary.rb +74 -0
  28. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  29. data/lib/saboteur/engine/candidates.rb +59 -7
  30. data/lib/saboteur/engine/coverage_store.rb +53 -6
  31. data/lib/saboteur/engine/data_inputs.rb +43 -0
  32. data/lib/saboteur/engine/diagnostics.rb +176 -2
  33. data/lib/saboteur/engine/diff_scope.rb +18 -0
  34. data/lib/saboteur/engine/distribution.rb +54 -13
  35. data/lib/saboteur/engine/result_cache.rb +10 -5
  36. data/lib/saboteur/engine/run_fingerprint.rb +40 -10
  37. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  38. data/lib/saboteur/engine/scheduler.rb +99 -36
  39. data/lib/saboteur/engine/subject_selection.rb +24 -1
  40. data/lib/saboteur/engine/suite_files.rb +8 -1
  41. data/lib/saboteur/engine/test_guess.rb +52 -0
  42. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  43. data/lib/saboteur/engine/verdict_check.rb +7 -6
  44. data/lib/saboteur/engine.rb +257 -22
  45. data/lib/saboteur/execution/protocol.rb +2 -1
  46. data/lib/saboteur/execution/reevaluator.rb +79 -14
  47. data/lib/saboteur/execution/runner.rb +120 -17
  48. data/lib/saboteur/execution/sandbox.rb +271 -0
  49. data/lib/saboteur/execution/stale_captures.rb +43 -0
  50. data/lib/saboteur/execution/supervisor.rb +113 -9
  51. data/lib/saboteur/execution/worker_pool.rb +20 -4
  52. data/lib/saboteur/execution/wrapper.rb +45 -0
  53. data/lib/saboteur/frameworks/rails.rb +329 -13
  54. data/lib/saboteur/gates.rb +83 -0
  55. data/lib/saboteur/help.rb +63 -23
  56. data/lib/saboteur/ignore_list.rb +76 -17
  57. data/lib/saboteur/inline_suppression.rb +65 -0
  58. data/lib/saboteur/install.rb +132 -0
  59. data/lib/saboteur/integration/base.rb +1 -0
  60. data/lib/saboteur/integration/focus_guard.rb +83 -0
  61. data/lib/saboteur/integration/rspec.rb +68 -4
  62. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  63. data/lib/saboteur/mutation_source/builtin.rb +30 -5
  64. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  65. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  67. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  68. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  69. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  70. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  71. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  72. data/lib/saboteur/preview.rb +106 -0
  73. data/lib/saboteur/replay.rb +1 -1
  74. data/lib/saboteur/reporter/agent.rb +87 -0
  75. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  76. data/lib/saboteur/reporter/agent_commands.rb +67 -0
  77. data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
  78. data/lib/saboteur/reporter/agent_summary.rb +137 -0
  79. data/lib/saboteur/reporter/findings.rb +12 -19
  80. data/lib/saboteur/reporter/history_paths.rb +38 -0
  81. data/lib/saboteur/reporter/history_record.rb +132 -0
  82. data/lib/saboteur/reporter/html.rb +61 -2
  83. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  84. data/lib/saboteur/reporter/next_steps.rb +30 -0
  85. data/lib/saboteur/reporter/palette.rb +37 -0
  86. data/lib/saboteur/reporter/score_history.rb +123 -0
  87. data/lib/saboteur/reporter/score_table.rb +57 -0
  88. data/lib/saboteur/reporter/stryker.rb +25 -5
  89. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  90. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  91. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  92. data/lib/saboteur/reporter/terminal.rb +157 -42
  93. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  94. data/lib/saboteur/result.rb +86 -10
  95. data/lib/saboteur/score.rb +89 -0
  96. data/lib/saboteur/selection/coverage_map.rb +119 -13
  97. data/lib/saboteur/selection/git_diff.rb +28 -1
  98. data/lib/saboteur/selection/recorder.rb +55 -13
  99. data/lib/saboteur/subject.rb +40 -4
  100. data/lib/saboteur/subject_finder.rb +62 -12
  101. data/lib/saboteur/version.rb +1 -1
  102. data/lib/saboteur/working_tree.rb +56 -0
  103. data/lib/saboteur.rb +10 -0
  104. metadata +58 -2
@@ -5,7 +5,7 @@ module Saboteur
5
5
  class Engine
6
6
  # Checks each kill against the method re-evaluated unchanged: a kill
7
7
  # stands only if the same tests, in the same order, up to and including
8
- # the killer, pass without the mutant (so a test that fails only after
8
+ # the last killer, pass without the mutant (so a test that fails only after
9
9
  # another test left state behind cannot claim a kill). A kill with no
10
10
  # failing test named (an exit, a suite-level failure) is checked on all
11
11
  # the mutant's tests. A kill that fails the check becomes an error.
@@ -39,11 +39,12 @@ module Saboteur
39
39
  result.status == :killed && result.killed_by.empty?
40
40
  end
41
41
 
42
- # The tests the mutant ran, in order, up to and including the killer.
42
+ # The tests the mutant ran, in order, up to and including the last
43
+ # killer: a matrix run lists every failing test, and each of them must
44
+ # pass under the unchanged method in this same sequence.
43
45
  def sequence(result)
44
- killer = result.killed_by.first
45
- position = result.covered_by.index(killer)
46
- position ? result.covered_by.first(position + 1) : [killer]
46
+ position = result.killed_by.filter_map { |test| result.covered_by.index(test) }.max
47
+ position ? result.covered_by.first(position + 1) : [result.killed_by.first]
47
48
  end
48
49
 
49
50
  # Runs each distinct kill sequence once. Returns those that fail, each
@@ -67,7 +68,7 @@ module Saboteur
67
68
 
68
69
  if named_kill?(result) && failing_runs.key?(sequence(result))
69
70
  why = failing_runs[sequence(result)]
70
- result.as_error("killing test #{result.killed_by.first} also fails with the method unchanged" \
71
+ result.as_error("killing test #{sequence(result).last} also fails with the method unchanged" \
71
72
  "#{": #{why}" if why}", rejected_kill: true)
72
73
  elsif failing.include?(result)
73
74
  result.as_error("tests also fail with the method unchanged", rejected_kill: true)
@@ -4,6 +4,8 @@
4
4
  require "fileutils"
5
5
  require "json"
6
6
  require_relative "replay"
7
+ require_relative "mutation_source/hazard_guard"
8
+ require_relative "baseline/run_evidence"
7
9
 
8
10
  module Saboteur
9
11
  # A single-use instance runs one pipeline: boot the target, select subjects, fetch the
@@ -11,37 +13,122 @@ module Saboteur
11
13
  # engine/; this class only wires them together.
12
14
  class Engine
13
15
  # Everything besides sources and tests that can change a verdict.
14
- VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources arid timeout_factor timeout_min
15
- max_memory_mb max_processes seed tests].freeze
16
+ VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators arid timeout_factor timeout_min
17
+ max_memory_mb max_processes seed tests whole_file].freeze
16
18
 
17
19
  attr_reader :config, :hooks
20
+ # The specs --guess-tests narrowed the run to, or nil when it did not.
21
+ attr_reader :guessed_tests
18
22
 
19
- def initialize(config, err: $stderr, digest: nil)
23
+ def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [])
20
24
  @config = config
21
25
  @hooks = Hooks.new
22
26
  @diagnostics = Diagnostics.new(err)
23
27
  @root = File.realpath(config.root)
24
28
  @skipped_subjects = {}
25
29
  @replay = Replay.new(digest: digest) if digest
30
+ if capture_evidence && (digest || config["since"] || config["incremental"])
31
+ raise ConfigError, "baseline evidence requires fresh whole-scope execution: no since, replay or incremental"
32
+ end
33
+
34
+ @capture_evidence = capture_evidence
35
+ @evidence_files = evidence_files
36
+ @stable_suite = false
26
37
  end
27
38
 
28
39
  def call
29
40
  Boot.refuse_recursion!
41
+ refuse_fail_fast_evidence
30
42
  started = monotonic
43
+ guess_tests
44
+ @run_evidence = Baseline::RunEvidence.new(config: config, extra_files: @evidence_files) if @capture_evidence
31
45
  ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
46
+ warn_custom_map
32
47
  boot = Boot.new(config, hooks)
33
- integration = boot.load_target
34
- @rails = boot.rails
35
- files = config.included_files
36
- subjects, scope = select_subjects(files)
37
- results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
38
- @diagnostics.warnings.concat(ignore_list.apply(results))
48
+ begin
49
+ integration, files, load_lines = boot_target(boot)
50
+ @rails = boot.rails
51
+ subjects, scope = select_subjects(files)
52
+ results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
53
+ @diagnostics.warnings.concat(ignore_list.apply(results))
54
+ build_report(results, subjects, scope, started)
55
+ ensure
56
+ # The run boundary for the per-worker databases: cleanup runs however
57
+ # the call ends - a boot that raised partway through, a baseline or
58
+ # coverage failure, no subjects, no runnable work - so a host that
59
+ # embeds Engine and calls it repeatedly never holds copies or
60
+ # snapshots until process exit. cleanup_workers is idempotent; the
61
+ # Rails profile's at_exit handler stays as the fallback for an
62
+ # abrupt process exit.
63
+ safely_cleanup_workers(boot.rails)
64
+ end
65
+ end
66
+
67
+ def warn_custom_map
68
+ @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
69
+ end
70
+
71
+ # Stale entries and duplicate literals never fail a run; they warn so the
72
+ # map does not rot silently. doctor fails on stale keys.
73
+ def load_custom_map
74
+ @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
75
+ end
76
+
77
+ def custom_map_warnings(map)
78
+ path = config.path("custom_map")
79
+ warnings = map.stale_keys.map { |key| "stale custom map key #{key}: matches no file" }
80
+ return warnings unless path && File.file?(path)
81
+
82
+ warnings + map.duplicate_keys(path).map { |key| "duplicate custom map key #{key}: Ruby keeps the last" }
83
+ end
84
+
85
+ private
86
+
87
+ # --guess-tests with --since: scope the run to the specs found next to
88
+ # the changed files, as --test would, but only when each changed file
89
+ # has one. Otherwise the whole suite runs, as without the flag.
90
+ def guess_tests
91
+ return unless config["guess_tests"]
92
+
93
+ guess = TestGuess.new(config).call
94
+ message =
95
+ if guess.changed.zero?
96
+ "--guess-tests: no changed source file to guess specs for, so the whole suite runs"
97
+ elsif guess.unmatched.any?
98
+ "--guess-tests: no spec found for #{guess.unmatched.join(', ')}, so the whole suite runs " \
99
+ "(name specs after their source files or pass --test)"
100
+ else
101
+ # The guesses now stand as explicit tests, like --test.
102
+ @config = Config.new(config.to_h.merge("tests" => guess.specs, "guess_tests" => false), root: config.root)
103
+ @guessed_tests = guess.specs
104
+ "--guess-tests: running only #{guess.specs.join(', ')}. A spec that reaches a changed file only " \
105
+ "through another file does not run, so a mutant only it would kill can survive, and a line " \
106
+ "no guessed spec executes is reported as NoCoverage"
107
+ end
108
+ @diagnostics.warn(message)
109
+ @diagnostics.inform(message)
110
+ end
111
+
112
+ # The run's immutable report, built once the last result is in.
113
+ def build_report(results, subjects, scope, started)
39
114
  Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
40
115
  invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
41
- review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects)
116
+ review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
117
+ diff_scope: scope, generation_events: @diagnostics.generation_events,
118
+ omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
119
+ hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
120
+ matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions)
42
121
  end
43
122
 
44
- private
123
+ def complete_evidence(results, subjects)
124
+ return nil unless @run_evidence
125
+
126
+ @run_evidence.plan(subjects, subjects.map { [] }) unless @evidence_planned
127
+ @run_evidence.finish(results: results, stable_suite: @stable_suite, skipped_subjects: @skipped_subjects,
128
+ generation_events: @diagnostics.generation_events, omissions: @diagnostics.omissions,
129
+ discovery_failures: @diagnostics.discovery_failures,
130
+ inline_suppressions: @diagnostics.inline_suppressions)
131
+ end
45
132
 
46
133
  # The subjects to mutate and, in diff mode, the scope of changed lines.
47
134
  def select_subjects(files)
@@ -54,9 +141,10 @@ module Saboteur
54
141
  [subjects, scope]
55
142
  end
56
143
 
57
- def mutate(subjects, files, integration, scope)
144
+ def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
145
+ warn_about_unscoped_suite(integration)
58
146
  check_baseline(integration)
59
- map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
147
+ map = CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
60
148
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
61
149
  inform_about_run(subjects, map)
62
150
  cache = result_cache
@@ -71,30 +159,92 @@ module Saboteur
71
159
  # mutant when serial. Pack the heap now that the map and candidates
72
160
  # are retained, to reduce later copying of shared pages.
73
161
  Execution::ForkReadiness.prepare_heap
74
- results = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
75
- cache: cache, on_start: method(:start_worker)).run(runnable_subjects, runnable_lists)
162
+ results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
76
163
  cache&.save
77
164
  results
78
165
  end
79
166
 
167
+ def run_candidates(subjects, lists, scheduler, cache, ignore_list)
168
+ @diagnostics.start_progress(lists.sum(&:size))
169
+ distribution = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
170
+ cache: cache, on_start: method(:start_worker),
171
+ after_subject: canary_check, stop_after: fail_fast_check(ignore_list))
172
+ distribution.run(subjects, lists).tap { record_early_stop(distribution.unreached) }
173
+ ensure
174
+ @diagnostics.finish_progress
175
+ end
176
+
177
+ # Baseline evidence certifies a whole selection; a run that may stop early
178
+ # cannot.
179
+ def refuse_fail_fast_evidence
180
+ return unless @capture_evidence && config["fail_fast"]
181
+
182
+ raise ConfigError, "fail_fast cannot be used with baseline evidence: it certifies the whole selection"
183
+ end
184
+
185
+ # Subjects that were selected but never ran, with candidates, are part of
186
+ # the report: the verdict covers less than the selection.
187
+ def record_early_stop(unreached)
188
+ return if unreached.empty?
189
+
190
+ @early_stop = EarlyStop.new(reason: "fail_fast", unreached: unreached.map(&:id))
191
+ end
192
+
193
+ # --fail-fast: stop after the first subject that has a survivor the
194
+ # ignore list does not accept. Granularity is one subject.
195
+ def fail_fast_check(ignore_list)
196
+ return nil unless config["fail_fast"]
197
+
198
+ lambda do |results|
199
+ survivor = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
200
+ @diagnostics.inform("--fail-fast: stopping after the first subject with a survivor") if survivor
201
+ survivor
202
+ end
203
+ end
204
+
80
205
  def inform_about_run(subjects, map)
81
206
  @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
82
207
  warn_about_threads
83
208
  end
84
209
 
210
+ # A --since run without --test runs the loaded suite twice for the
211
+ # baselines; a cold coverage cache adds one more pass. Say so
212
+ # before those hours pass, with the way to scope it.
213
+ def warn_about_unscoped_suite(integration)
214
+ return unless config["since"] && config["tests"].empty?
215
+
216
+ message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
217
+ "(#{integration.test_files.size} spec files). " \
218
+ "Pass --test to run only the specs you name: faster, but a mutant of a changed method is then killed " \
219
+ "only by those specs, so specs elsewhere that call this code are not run. " \
220
+ "--guess-tests does the same with the specs named after the changed files, " \
221
+ "a guess that can miss specs elsewhere."
222
+ @diagnostics.warn(message)
223
+ @diagnostics.inform(message)
224
+ end
225
+
85
226
  def check_baseline(integration)
86
227
  BaselineGuard.new(config: config, integration: integration, hooks: hooks,
87
228
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
229
+ @stable_suite = true
88
230
  end
89
231
 
90
232
  def scheduler_for(map, integration, cache)
91
- reevaluator = Execution::Reevaluator.new(config["reeval"])
233
+ reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
92
234
  runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
93
- supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"])
235
+ supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"],
236
+ sandbox: sandbox_hook)
94
237
  Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
95
238
  cache: cache)
96
239
  end
97
240
 
241
+ # The sandbox hook for each mutant child (#152); the policy and its
242
+ # one-time note live in Engine::SandboxPolicy.
243
+ def sandbox_hook
244
+ @sandbox_policy ||= SandboxPolicy.new(config: config, root: @root, diagnostics: @diagnostics)
245
+ @sandbox_policy.hook
246
+ end
247
+
98
248
  def result_cache
99
249
  return nil if @replay
100
250
  return nil unless config["incremental"]
@@ -104,6 +254,14 @@ module Saboteur
104
254
  suite_digest: SuiteFiles.new(config).digest)
105
255
  end
106
256
 
257
+ # A cleanup failure must never mask the run's own outcome - not even
258
+ # one cleanup_workers did not anticipate.
259
+ def safely_cleanup_workers(rails)
260
+ rails&.cleanup_workers
261
+ rescue StandardError => e
262
+ @diagnostics.warn(e.message)
263
+ end
264
+
107
265
  # In each worker: its own database, then the user's worker_start hooks.
108
266
  def start_worker(number)
109
267
  @rails&.start_worker(number)
@@ -111,19 +269,43 @@ module Saboteur
111
269
  end
112
270
 
113
271
  # Each subject's candidates, narrowed to changed lines in diff mode.
272
+ # The hazard guard is off only when the user explicitly asks for the
273
+ # dangerous mutants (issue #102); the canary then still watches the tree.
274
+ def hazard_guard
275
+ return nil if config["allow_hazardous"]
276
+
277
+ MutationSource::HazardGuard.new(config["hazard"])
278
+ end
279
+
280
+ # Checks the tree after each subject's mutants; a vanished file means a
281
+ # mutant deleted it, so abort with the most recent mutant's digest.
282
+ def canary_check
283
+ canary = Canary.new(@root, cache_dir: config["cache_dir"])
284
+ lambda do |subject, results|
285
+ last = results.reject(&:reused?).last || results.last
286
+ digest = last&.mutation&.digest(subject_start: subject.start_byte,
287
+ original_source: subject.file_source)
288
+ canary.check!(subject: subject, digest: digest)
289
+ end
290
+ end
291
+
114
292
  def candidate_lists(subjects, scope, map)
115
293
  return [@replay_candidate] if @replay
116
294
 
117
- candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
295
+ candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
118
296
  selection = CandidateSelection.new(candidates: candidates, map: map,
119
297
  limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
120
298
  subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
121
299
  @skipped_subjects = selection.skipped_subjects
300
+ if @run_evidence
301
+ @run_evidence.plan(subjects, subjects.map { |subject| selection.selected.fetch(subject) })
302
+ @evidence_planned = true
303
+ end
122
304
  end
123
305
  end
124
306
 
125
307
  def replay_subject(subjects, scope)
126
- candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
308
+ candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
127
309
  subject, candidate = @replay.select(subjects, candidates, scope: scope)
128
310
  @replay_candidate = [candidate]
129
311
  [[subject], nil]
@@ -143,10 +325,58 @@ module Saboteur
143
325
  end
144
326
  end
145
327
 
328
+ # Threads do not survive fork, so name them: with the name the owner
329
+ # gave the thread, else the first frame of its backtrace, so the user
330
+ # can tell whether they matter (a connection pool reaper, Sidekiq, an
331
+ # SDK's background flusher).
146
332
  def warn_about_threads
147
- others = Thread.list.size - 1
148
- @diagnostics.warn("#{others} other thread(s) alive at fork time; they do not run in children") if
149
- others.positive?
333
+ others = Thread.list - [Thread.current]
334
+ return if others.empty?
335
+
336
+ names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
337
+ list = names.join(", ")
338
+ @diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
339
+ end
340
+
341
+ # The lines that ran while the app and spec files loaded: the executable
342
+ # counts from its first line, so this snapshot holds the whole boot.
343
+ # Read here, in the parent: a forked child does not inherit Coverage
344
+ # counters. They become the map's load lines, which tell ran-at-load
345
+ # mutants apart from uncovered ones.
346
+ def capture_load_lines(files)
347
+ return {} unless Coverage.state == :suspended
348
+
349
+ wanted = files.to_set { |file| Selection::CoverageMap.canonical(file) }
350
+ Coverage.result(stop: false, clear: false).each_with_object({}) do |(path, data), hits|
351
+ file = Selection::CoverageMap.canonical(path)
352
+ next unless wanted.include?(file)
353
+
354
+ lines = data[:lines].each_with_index.filter_map { |count, index| index + 1 if count&.positive? }
355
+ lines -= uncalled_shared_lines(data)
356
+ hits[file] = lines unless lines.empty?
357
+ end
358
+ end
359
+
360
+ # A one-line or endless definition executes its def line even when its
361
+ # body is never called. If any sibling on that physical line is uncalled,
362
+ # discard its ambiguous line evidence rather than crediting every body.
363
+ def uncalled_shared_lines(data)
364
+ shared = data.fetch(:methods, {}).select { |(_owner, _name, first, _column, last, *), _| first == last }
365
+ calls = shared.group_by { |(_owner, _name, first, *), _count| first }
366
+ calls.filter_map { |line, entries| line if entries.any? { |_key, count| count.zero? } }
367
+ end
368
+
369
+ # Loads the target and stops coverage, returning what the rest of the
370
+ # run needs from that boot.
371
+ def boot_target(boot)
372
+ integration = boot.load_target
373
+ files = config.included_files
374
+ [integration, files, suspend_coverage(files)]
375
+ end
376
+
377
+ def suspend_coverage(files)
378
+ Coverage.suspend if Coverage.state == :running
379
+ capture_load_lines(files)
150
380
  end
151
381
 
152
382
  def monotonic
@@ -158,15 +388,20 @@ end
158
388
  require_relative "engine/diagnostics"
159
389
  require_relative "engine/boot"
160
390
  require_relative "engine/subject_selection"
391
+ require_relative "engine/unchanged_syntax"
161
392
  require_relative "engine/suite_files"
393
+ require_relative "engine/test_guess"
394
+ require_relative "engine/sandbox_policy"
162
395
  require_relative "engine/coverage_store"
163
396
  require_relative "engine/baseline_guard"
397
+ require_relative "engine/bytecode_equivalence"
164
398
  require_relative "engine/candidates"
165
399
  require_relative "engine/candidate_selection"
166
400
  require_relative "engine/diff_scope"
167
401
  require_relative "engine/result_cache"
168
402
  require_relative "engine/verdict_check"
169
403
  require_relative "engine/scheduler"
404
+ require_relative "engine/canary"
170
405
  require_relative "engine/distribution"
171
406
 
172
407
  require_relative "engine/run_fingerprint"
@@ -8,9 +8,10 @@ module Saboteur
8
8
  # What a mutant child tells its supervisor, one JSON object per line on
9
9
  # a pipe. Every message carries the protocol version.
10
10
  #
11
+ # captures a stale Method capture of the original def exists (site, stale)
11
12
  # started the mutated method is in place; tests are about to run
12
13
  # result passed, failed (ids), executed (ids), runtime, exited (status
13
- # when a test called exit)
14
+ # when a test called exit), replacement_called (false only after a verified trace)
14
15
  # error message, backtrace: the harness failed, not the tests
15
16
  #
16
17
  # A child that dies after "started" without "result" crashed while its
@@ -12,28 +12,76 @@ module Saboteur
12
12
  # nesting - rebuild the original `module A; class B` nesting on one line
13
13
  # and eval at top level, so constant lookup matches the file.
14
14
  #
15
+ # A subject named in whole_file skips both: the child evaluates the whole
16
+ # file with the mutated method spliced in, the way mutineer does. That is
17
+ # the way in for methods surgical re-evaluation refuses, at a price: the
18
+ # file's top-level code runs again in the child, so load-time side effects
19
+ # repeat and constants it assigns are rebuilt. It reaches only methods
20
+ # SubjectFinder already finds.
21
+ #
15
22
  # Both keep the original file and line numbers, carry a frozen_string_literal
16
23
  # magic comment, and restore the method's visibility afterwards.
17
24
  class Reevaluator
18
25
  STRATEGIES = %w[class_eval nesting].freeze
19
26
  FROZEN_HEADER = "# frozen_string_literal: true\n"
20
27
 
21
- def initialize(strategy = "nesting")
28
+ def initialize(strategy = "nesting", whole_file: [])
22
29
  raise Error, "unknown reeval strategy: #{strategy}" unless STRATEGIES.include?(strategy.to_s)
23
30
 
24
31
  @strategy = strategy.to_s
32
+ @whole_file = whole_file
33
+ end
34
+
35
+ # "file:line" of another def in the subject's own file that replaced it:
36
+ # a later definition there won, so the mutant could never run. Only a
37
+ # positively identified different def counts. nil when the subject is not
38
+ # whole-file, its def is the one in place, or this cannot be told (a
39
+ # method from another file, such as an unknown forwarding wrapper, no
40
+ # Ruby source, or no method): the caller then keeps the verdict.
41
+ def replaced_definition(subject)
42
+ return nil unless whole_file?(subject)
43
+
44
+ owner = resolve_owner(subject)
45
+ target = subject.singleton ? owner.singleton_class : owner
46
+ method = target.instance_method(subject.name)
47
+ # A def-time wrapper (a sorbet sig) replaces the method; its own
48
+ # source_location is the wrapper's, not the def's.
49
+ file, line = (Wrapper.original_def(method) || method).source_location
50
+ return nil unless file == subject.file && line != subject.start_line
51
+
52
+ "#{file}:#{line}"
53
+ rescue StandardError
54
+ nil
25
55
  end
26
56
 
27
- def call(subject, text)
57
+ # True when the subject's id matches a whole_file entry (exact or a
58
+ # File.fnmatch glob).
59
+ def whole_file?(subject)
60
+ @whole_file.any? { |pattern| File.fnmatch?(pattern, subject.id) }
61
+ end
62
+
63
+ # prefix: source evaluated right before the def text, at the lines it
64
+ # occupies in the file - a sorbet `sig { ... }` call, so the wrapper
65
+ # gem re-wraps the mutant and its runtime checks stay on.
66
+ def call(subject, text, prefix: nil)
67
+ return eval_whole_file(subject, text) if whole_file?(subject)
68
+
28
69
  owner = resolve_owner(subject)
29
70
  target = subject.singleton ? owner.singleton_class : owner
30
71
  visibility = visibility_of(target, subject.name)
31
72
  module_function = module_function?(owner, subject)
73
+ copy_visibility = visibility_of(owner.singleton_class, subject.name) if module_function
32
74
  aliases = aliases_of(target, subject.name)
33
75
 
34
- @strategy == "nesting" ? eval_nested(subject, text) : eval_in_owner(owner, subject, text)
76
+ extra_lines = prefix ? prefix.count("\n") + 1 : 0
77
+ full_text = prefix ? "#{prefix}\n#{text}" : text
78
+ if @strategy == "nesting"
79
+ eval_nested(subject, full_text, extra_lines)
80
+ else
81
+ eval_in_owner(owner, subject, full_text, extra_lines)
82
+ end
35
83
 
36
- restore(owner, target, subject.name, visibility, module_function)
84
+ restore(owner, target, subject.name, visibility, copy_visibility)
37
85
  repoint(target, subject.name, aliases)
38
86
  end
39
87
 
@@ -82,29 +130,45 @@ module Saboteur
82
130
  def module_function?(owner, subject)
83
131
  return false if subject.singleton || owner.is_a?(Class)
84
132
  return false unless owner.private_method_defined?(subject.name, false)
85
- return false unless owner.singleton_class.public_method_defined?(subject.name, false)
133
+ return false unless owner.singleton_class.method_defined?(subject.name, false) ||
134
+ owner.singleton_class.private_method_defined?(subject.name, false)
86
135
 
87
136
  owner.instance_method(subject.name).source_location ==
88
137
  owner.singleton_class.instance_method(subject.name).source_location
89
138
  end
90
139
 
91
- def eval_in_owner(owner, subject, text)
92
- code, line = with_magic_comment(subject, text)
140
+ # The file as it is on disk, with the method's bytes replaced by text,
141
+ # evaluated at top level under the file's own name and first line.
142
+ # Redefinition warnings are expected and silenced.
143
+ def eval_whole_file(subject, text)
144
+ source = subject.file_source.dup.force_encoding(Encoding::UTF_8)
145
+ spliced = source.byteslice(0, subject.start_byte) + text + source.byteslice(subject.end_byte..)
146
+ verbose = $VERBOSE
147
+ $VERBOSE = nil
148
+ TOPLEVEL_BINDING.eval(spliced, subject.file, 1)
149
+ ensure
150
+ $VERBOSE = verbose
151
+ end
152
+
153
+ def eval_in_owner(owner, subject, text, extra_lines)
154
+ code, line = with_magic_comment(subject, text, extra_lines)
93
155
  eval_target = subject.sclass ? owner.singleton_class : owner
94
156
  eval_target.class_eval(code, subject.file, line)
95
157
  end
96
158
 
97
- def eval_nested(subject, text)
159
+ def eval_nested(subject, text, extra_lines)
98
160
  headers = subject.nesting.map(&:header)
99
161
  wrapped = "#{headers.join('; ')}; #{text}; #{(['end'] * headers.size).join('; ')}"
100
- code, line = with_magic_comment(subject, wrapped)
162
+ code, line = with_magic_comment(subject, wrapped, extra_lines)
101
163
  TOPLEVEL_BINDING.eval(code, subject.file, line)
102
164
  end
103
165
 
104
- def with_magic_comment(subject, code)
105
- return [code, subject.start_line] unless subject.frozen_literals
166
+ # The first line of code sits extra_lines above the def, so the def
167
+ # keeps its file line numbers.
168
+ def with_magic_comment(subject, code, extra_lines)
169
+ return [code, subject.start_line - extra_lines] unless subject.frozen_literals
106
170
 
107
- [FROZEN_HEADER + code, subject.start_line - 1]
171
+ [FROZEN_HEADER + code, subject.start_line - extra_lines - 1]
108
172
  end
109
173
 
110
174
  # Methods defined with alias/alias_method copy the old body. Callers
@@ -132,9 +196,10 @@ module Saboteur
132
196
  end
133
197
  end
134
198
 
135
- def restore(owner, target, name, visibility, module_function)
136
- if module_function
199
+ def restore(owner, target, name, visibility, copy_visibility)
200
+ if copy_visibility
137
201
  owner.send(:module_function, name)
202
+ owner.singleton_class.send(copy_visibility, name)
138
203
  elsif visibility != :public
139
204
  target.send(visibility, name)
140
205
  end