saboteur 0.2.0 → 0.4.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -1
  3. data/README.md +526 -73
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/baseline_health.rb +116 -0
  7. data/lib/saboteur/cli.rb +305 -30
  8. data/lib/saboteur/config.rb +65 -19
  9. data/lib/saboteur/config_file.rb +97 -0
  10. data/lib/saboteur/custom_map.rb +170 -0
  11. data/lib/saboteur/doctor.rb +88 -0
  12. data/lib/saboteur/engine/baseline_guard.rb +116 -0
  13. data/lib/saboteur/engine/boot.rb +9 -0
  14. data/lib/saboteur/engine/candidate_selection.rb +35 -0
  15. data/lib/saboteur/engine/coverage_store.rb +40 -6
  16. data/lib/saboteur/engine/data_inputs.rb +43 -0
  17. data/lib/saboteur/engine/diagnostics.rb +28 -0
  18. data/lib/saboteur/engine/diff_scope.rb +8 -0
  19. data/lib/saboteur/engine/distribution.rb +4 -2
  20. data/lib/saboteur/engine/run_fingerprint.rb +344 -0
  21. data/lib/saboteur/engine/scheduler.rb +7 -16
  22. data/lib/saboteur/engine/suite_files.rb +8 -1
  23. data/lib/saboteur/engine/verdict_check.rb +4 -2
  24. data/lib/saboteur/engine.rb +125 -25
  25. data/lib/saboteur/execution/fork_readiness.rb +46 -0
  26. data/lib/saboteur/execution/reevaluator.rb +7 -4
  27. data/lib/saboteur/execution/supervisor.rb +23 -11
  28. data/lib/saboteur/frameworks/rails.rb +329 -13
  29. data/lib/saboteur/help.rb +210 -0
  30. data/lib/saboteur/ignore_list.rb +71 -17
  31. data/lib/saboteur/install.rb +132 -0
  32. data/lib/saboteur/integration/base.rb +8 -0
  33. data/lib/saboteur/integration/rspec.rb +69 -4
  34. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  35. data/lib/saboteur/mutation_source/builtin.rb +14 -3
  36. data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
  37. data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
  38. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  39. data/lib/saboteur/replay.rb +33 -0
  40. data/lib/saboteur/reporter/agent.rb +87 -0
  41. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  42. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  43. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  44. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  45. data/lib/saboteur/reporter/findings.rb +131 -0
  46. data/lib/saboteur/reporter/html.rb +213 -0
  47. data/lib/saboteur/reporter/replay.rb +23 -0
  48. data/lib/saboteur/reporter/stryker.rb +21 -4
  49. data/lib/saboteur/reporter/terminal.rb +76 -2
  50. data/lib/saboteur/result.rb +61 -9
  51. data/lib/saboteur/score.rb +89 -0
  52. data/lib/saboteur/version.rb +1 -1
  53. data/lib/saboteur/working_tree.rb +48 -0
  54. data/lib/saboteur.rb +7 -0
  55. metadata +30 -2
@@ -3,66 +3,136 @@
3
3
 
4
4
  require "fileutils"
5
5
  require "json"
6
+ require_relative "replay"
6
7
 
7
8
  module Saboteur
8
- # One run, as a pipeline: boot the target, select subjects, fetch the
9
+ # A single-use instance runs one pipeline: boot the target, select subjects, fetch the
9
10
  # coverage map, then schedule each subject's mutants. The parts live in
10
11
  # engine/; this class only wires them together.
11
12
  class Engine
12
13
  # Everything besides sources and tests that can change a verdict.
13
14
  VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources arid timeout_factor timeout_min
14
- max_memory_mb max_processes seed].freeze
15
+ max_memory_mb max_processes seed tests].freeze
15
16
 
16
17
  attr_reader :config, :hooks
17
18
 
18
- def initialize(config, err: $stderr)
19
+ def initialize(config, err: $stderr, digest: nil)
19
20
  @config = config
20
21
  @hooks = Hooks.new
21
22
  @diagnostics = Diagnostics.new(err)
22
23
  @root = File.realpath(config.root)
24
+ @skipped_subjects = {}
25
+ @replay = Replay.new(digest: digest) if digest
23
26
  end
24
27
 
25
28
  def call
26
29
  Boot.refuse_recursion!
27
30
  started = monotonic
28
- ignore_list = IgnoreList.load(config.path("ignore_file"))
31
+ ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
32
+ @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
29
33
  boot = Boot.new(config, hooks)
30
- integration = boot.load_target
31
- @rails = boot.rails
32
- files = config.included_files
33
- subjects, scope = select_subjects(files)
34
- results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
35
- @diagnostics.warnings.concat(ignore_list.apply(results))
36
- Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
37
- invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
38
- review: !scope.nil?, seed: config["seed"])
34
+ begin
35
+ integration = boot.load_target
36
+ @rails = boot.rails
37
+ files = config.included_files
38
+ subjects, scope = select_subjects(files)
39
+ results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
40
+ @diagnostics.warnings.concat(ignore_list.apply(results))
41
+ build_report(results, subjects, scope, started)
42
+ ensure
43
+ # The run boundary for the per-worker databases: cleanup runs however
44
+ # the call ends - a boot that raised partway through, a baseline or
45
+ # coverage failure, no subjects, no runnable work - so a host that
46
+ # embeds Engine and calls it repeatedly never holds copies or
47
+ # snapshots until process exit. cleanup_workers is idempotent; the
48
+ # Rails profile's at_exit handler stays as the fallback for an
49
+ # abrupt process exit.
50
+ safely_cleanup_workers(boot.rails)
51
+ end
52
+ end
53
+
54
+ # Stale entries and duplicate literals never fail a run; they warn so the
55
+ # map does not rot silently. doctor fails on stale keys.
56
+ def load_custom_map
57
+ @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
58
+ end
59
+
60
+ def custom_map_warnings(map)
61
+ path = config.path("custom_map")
62
+ warnings = map.stale_keys.map { |key| "stale custom map key #{key}: matches no file" }
63
+ return warnings unless path && File.file?(path)
64
+
65
+ warnings + map.duplicate_keys(path).map { |key| "duplicate custom map key #{key}: Ruby keeps the last" }
39
66
  end
40
67
 
41
68
  private
42
69
 
70
+ # The run's immutable report, built once the last result is in.
71
+ def build_report(results, subjects, scope, started)
72
+ Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
73
+ invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
74
+ review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
75
+ diff_scope: scope)
76
+ end
77
+
43
78
  # The subjects to mutate and, in diff mode, the scope of changed lines.
44
79
  def select_subjects(files)
45
80
  config.unmatched_patterns.each { |pattern| @diagnostics.warn("#{pattern} matches no file") }
46
81
  selection = SubjectSelection.new(config, @diagnostics)
47
82
  subjects = selection.select(files)
48
- [subjects, selection.changed_lines && DiffScope.new(selection.changed_lines)]
83
+ scope = selection.changed_lines && DiffScope.new(selection.changed_lines)
84
+ return replay_subject(subjects, scope) if @replay
85
+
86
+ [subjects, scope]
49
87
  end
50
88
 
51
89
  def mutate(subjects, files, integration, scope)
90
+ warn_about_unscoped_suite(integration)
91
+ check_baseline(integration)
52
92
  map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
53
93
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
54
- @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
55
- warn_about_threads
94
+ inform_about_run(subjects, map)
56
95
  cache = result_cache
57
96
  scheduler = scheduler_for(map, integration, cache)
97
+ lists = candidate_lists(subjects, scope, map)
98
+ work = subjects.zip(lists).reject { |_, list| list.empty? }
99
+ return [] if work.empty?
100
+
101
+ runnable_subjects, runnable_lists = work.transpose
58
102
  @rails&.prepare_workers(config["jobs"])
103
+ # The execution phase forks next: the worker batch, or one child per
104
+ # mutant when serial. Pack the heap now that the map and candidates
105
+ # are retained, to reduce later copying of shared pages.
106
+ Execution::ForkReadiness.prepare_heap
59
107
  results = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
60
- cache: cache, on_start: method(:start_worker)).run(subjects, candidate_lists(subjects,
61
- scope))
108
+ cache: cache, on_start: method(:start_worker)).run(runnable_subjects,
109
+ runnable_lists)
62
110
  cache&.save
63
111
  results
64
112
  end
65
113
 
114
+ def inform_about_run(subjects, map)
115
+ @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
116
+ warn_about_threads
117
+ end
118
+
119
+ # A --since run without --test runs the loaded suite twice for the
120
+ # baselines; a cold coverage cache adds one more pass. Say so
121
+ # before those hours pass, with the way to scope it.
122
+ def warn_about_unscoped_suite(integration)
123
+ return unless config["since"] && config["tests"].empty?
124
+
125
+ message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
126
+ "(#{integration.test_files.size} spec files). Pass --test to scope it."
127
+ @diagnostics.warn(message)
128
+ @diagnostics.inform(message)
129
+ end
130
+
131
+ def check_baseline(integration)
132
+ BaselineGuard.new(config: config, integration: integration, hooks: hooks,
133
+ supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
134
+ end
135
+
66
136
  def scheduler_for(map, integration, cache)
67
137
  reevaluator = Execution::Reevaluator.new(config["reeval"])
68
138
  runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
@@ -72,6 +142,7 @@ module Saboteur
72
142
  end
73
143
 
74
144
  def result_cache
145
+ return nil if @replay
75
146
  return nil unless config["incremental"]
76
147
 
77
148
  inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] })
@@ -79,6 +150,14 @@ module Saboteur
79
150
  suite_digest: SuiteFiles.new(config).digest)
80
151
  end
81
152
 
153
+ # A cleanup failure must never mask the run's own outcome - not even
154
+ # one cleanup_workers did not anticipate.
155
+ def safely_cleanup_workers(rails)
156
+ rails&.cleanup_workers
157
+ rescue StandardError => e
158
+ @diagnostics.warn(e.message)
159
+ end
160
+
82
161
  # In each worker: its own database, then the user's worker_start hooks.
83
162
  def start_worker(number)
84
163
  @rails&.start_worker(number)
@@ -86,14 +165,24 @@ module Saboteur
86
165
  end
87
166
 
88
167
  # Each subject's candidates, narrowed to changed lines in diff mode.
89
- def candidate_lists(subjects, scope)
168
+ def candidate_lists(subjects, scope, map)
169
+ return [@replay_candidate] if @replay
170
+
90
171
  candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
91
- subjects.map do |subject|
92
- list = candidates.for(subject)
93
- scope ? scope.restrict(subject, list) : list
172
+ selection = CandidateSelection.new(candidates: candidates, map: map,
173
+ limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
174
+ subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
175
+ @skipped_subjects = selection.skipped_subjects
94
176
  end
95
177
  end
96
178
 
179
+ def replay_subject(subjects, scope)
180
+ candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
181
+ subject, candidate = @replay.select(subjects, candidates, scope: scope)
182
+ @replay_candidate = [candidate]
183
+ [[subject], nil]
184
+ end
185
+
97
186
  def supervisor(memory_factor: 1)
98
187
  limits = Execution::Supervisor::Limits.new(max_memory_mb: config["max_memory_mb"] * memory_factor,
99
188
  max_processes: config["max_processes"])
@@ -108,10 +197,17 @@ module Saboteur
108
197
  end
109
198
  end
110
199
 
200
+ # Threads do not survive fork, so name them: with the name the owner
201
+ # gave the thread, else the first frame of its backtrace, so the user
202
+ # can tell whether they matter (a connection pool reaper, Sidekiq, an
203
+ # SDK's background flusher).
111
204
  def warn_about_threads
112
- others = Thread.list.size - 1
113
- @diagnostics.warn("#{others} other thread(s) alive at fork time; they do not run in children") if
114
- others.positive?
205
+ others = Thread.list - [Thread.current]
206
+ return if others.empty?
207
+
208
+ names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
209
+ list = names.join(", ")
210
+ @diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
115
211
  end
116
212
 
117
213
  def monotonic
@@ -125,9 +221,13 @@ require_relative "engine/boot"
125
221
  require_relative "engine/subject_selection"
126
222
  require_relative "engine/suite_files"
127
223
  require_relative "engine/coverage_store"
224
+ require_relative "engine/baseline_guard"
128
225
  require_relative "engine/candidates"
226
+ require_relative "engine/candidate_selection"
129
227
  require_relative "engine/diff_scope"
130
228
  require_relative "engine/result_cache"
131
229
  require_relative "engine/verdict_check"
132
230
  require_relative "engine/scheduler"
133
231
  require_relative "engine/distribution"
232
+
233
+ require_relative "engine/run_fingerprint"
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Execution
6
+ # Gets the heap ready at a fork boundary.
7
+ #
8
+ # Boot and candidate construction can leave garbage, fragmentation and
9
+ # young objects in the heap. Forked children initially share its pages.
10
+ # Later collection and object writes can make pages private. Preparing the
11
+ # heap before a fork batch can reduce that copying. Call it once per phase
12
+ # boundary, not per fork: preparing again while siblings are alive can
13
+ # break sharing with those siblings.
14
+ module ForkReadiness
15
+ # Process.warmup (CRuby 3.3+) is made for this: a full collection that
16
+ # also promotes survivors to the old generation and compacts. Without
17
+ # it, a lone GC.compact still packs the heap (it runs a full collection
18
+ # itself first) but does not promise old-generation promotion. Without
19
+ # either, a full collection is the best that Ruby can do.
20
+ def self.prepare_heap
21
+ return warmup_heap if Process.respond_to?(:warmup)
22
+ return compact_heap if GC.respond_to?(:compact)
23
+
24
+ full_collection
25
+ end
26
+
27
+ def self.warmup_heap
28
+ Process.warmup
29
+ nil
30
+ end
31
+ private_class_method :warmup_heap
32
+
33
+ def self.compact_heap
34
+ GC.compact
35
+ nil
36
+ end
37
+ private_class_method :compact_heap
38
+
39
+ def self.full_collection
40
+ GC.start(full_mark: true, immediate_sweep: true)
41
+ nil
42
+ end
43
+ private_class_method :full_collection
44
+ end
45
+ end
46
+ end
@@ -29,11 +29,12 @@ module Saboteur
29
29
  target = subject.singleton ? owner.singleton_class : owner
30
30
  visibility = visibility_of(target, subject.name)
31
31
  module_function = module_function?(owner, subject)
32
+ copy_visibility = visibility_of(owner.singleton_class, subject.name) if module_function
32
33
  aliases = aliases_of(target, subject.name)
33
34
 
34
35
  @strategy == "nesting" ? eval_nested(subject, text) : eval_in_owner(owner, subject, text)
35
36
 
36
- restore(owner, target, subject.name, visibility, module_function)
37
+ restore(owner, target, subject.name, visibility, copy_visibility)
37
38
  repoint(target, subject.name, aliases)
38
39
  end
39
40
 
@@ -82,7 +83,8 @@ module Saboteur
82
83
  def module_function?(owner, subject)
83
84
  return false if subject.singleton || owner.is_a?(Class)
84
85
  return false unless owner.private_method_defined?(subject.name, false)
85
- return false unless owner.singleton_class.public_method_defined?(subject.name, false)
86
+ return false unless owner.singleton_class.method_defined?(subject.name, false) ||
87
+ owner.singleton_class.private_method_defined?(subject.name, false)
86
88
 
87
89
  owner.instance_method(subject.name).source_location ==
88
90
  owner.singleton_class.instance_method(subject.name).source_location
@@ -132,9 +134,10 @@ module Saboteur
132
134
  end
133
135
  end
134
136
 
135
- def restore(owner, target, name, visibility, module_function)
136
- if module_function
137
+ def restore(owner, target, name, visibility, copy_visibility)
138
+ if copy_visibility
137
139
  owner.send(:module_function, name)
140
+ owner.singleton_class.send(copy_visibility, name)
138
141
  elsif visibility != :public
139
142
  target.send(visibility, name)
140
143
  end
@@ -94,7 +94,10 @@ module Saboteur
94
94
  # Runs the block in a forked child with a pipe writer; the child exits
95
95
  # when the block returns. Returns a Result. cpu_seconds bounds every
96
96
  # descendant's CPU time (default: twice the deadline, at least 10 s).
97
- def run(timeout:, cpu_seconds: nil, &block)
97
+ # on_tick is called from the parent's wait loop after the fork, about
98
+ # every poll, so callers can report progress without a thread alive
99
+ # at fork time.
100
+ def run(timeout:, cpu_seconds: nil, on_tick: nil, &block)
98
101
  depth = ENV.fetch(CHILD_ENV, "0").to_i
99
102
  raise Error, "refusing to fork: saboteur children nested #{depth} deep (limit #{MAX_DEPTH})" if
100
103
  depth >= MAX_DEPTH
@@ -107,7 +110,7 @@ module Saboteur
107
110
  supervisor = Process.pid
108
111
  pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
109
112
  writer.close
110
- contain(pid, reader, started, timeout)
113
+ contain(pid, reader, started, timeout, on_tick)
111
114
  ensure
112
115
  reader&.close unless reader&.closed?
113
116
  end
@@ -168,24 +171,31 @@ module Saboteur
168
171
 
169
172
  # --- parent ---------------------------------------------------------
170
173
 
171
- def contain(pid, reader, started, timeout)
174
+ def contain(pid, reader, started, timeout, on_tick)
172
175
  tree = ProcessTree.new(pid)
173
176
  self.class.live[pid] = tree
174
177
  own_group(pid)
175
- payload, stop, detail = watch(tree, reader, started + timeout)
176
- exit_status = stop == :timeout ? terminate(pid) : nil
177
- leftover_pids = tree.kill_all - [pid]
178
- exit_status ||= reap(pid)
179
- self.class.live.delete(pid)
180
- reap_adopted(leftover_pids)
181
- reap_group(pid)
178
+ begin
179
+ payload, stop, detail = watch(tree, reader, started + timeout, on_tick)
180
+ ensure
181
+ # Containment holds on every path out of watch: a raising on_tick
182
+ # (progress IO failed) or any other error must not skip the
183
+ # kill/reap, or the child and its tree would run unsupervised
184
+ # until this process exits.
185
+ exit_status = stop == :timeout ? terminate(pid) : nil
186
+ leftover_pids = tree.kill_all - [pid]
187
+ exit_status ||= reap(pid)
188
+ self.class.live.delete(pid)
189
+ reap_adopted(leftover_pids)
190
+ reap_group(pid)
191
+ end
182
192
  Result.new(payload: payload, exit_status: exit_status, stopped_by: stop, detail: detail,
183
193
  runtime: monotonic - started, leftover_pids: leftover_pids)
184
194
  end
185
195
 
186
196
  # Reads until EOF, polling the tree's size and memory. Returns
187
197
  # [payload, stop, detail].
188
- def watch(tree, reader, deadline)
198
+ def watch(tree, reader, deadline, on_tick)
189
199
  payload = +""
190
200
  next_poll = monotonic + POLL_INTERVAL
191
201
  tree.live_members
@@ -193,6 +203,8 @@ module Saboteur
193
203
  now = monotonic
194
204
  return [payload, :timeout, "exceeded deadline"] if now >= deadline
195
205
 
206
+ on_tick&.call
207
+
196
208
  if now >= next_poll
197
209
  stop = over_limit(tree)
198
210
  return [payload, *stop] if stop