saboteur 0.4.0 → 0.6.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -0
  3. data/README.md +427 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +106 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +124 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +113 -122
  19. data/lib/saboteur/config.rb +96 -6
  20. data/lib/saboteur/doctor.rb +64 -9
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +62 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +7 -5
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +89 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +201 -19
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  48. data/lib/saboteur/frameworks/rails.rb +8 -10
  49. data/lib/saboteur/gates.rb +83 -0
  50. data/lib/saboteur/help.rb +32 -7
  51. data/lib/saboteur/ignore_list.rb +5 -0
  52. data/lib/saboteur/inline_suppression.rb +65 -0
  53. data/lib/saboteur/integration/base.rb +1 -0
  54. data/lib/saboteur/integration/focus_guard.rb +83 -0
  55. data/lib/saboteur/integration/rspec.rb +21 -1
  56. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  57. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  58. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  59. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  60. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  61. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  62. data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
  63. data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
  64. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  65. data/lib/saboteur/preview.rb +115 -0
  66. data/lib/saboteur/reporter/agent_commands.rb +19 -5
  67. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  68. data/lib/saboteur/reporter/agent_summary.rb +12 -6
  69. data/lib/saboteur/reporter/findings.rb +4 -19
  70. data/lib/saboteur/reporter/history_paths.rb +38 -0
  71. data/lib/saboteur/reporter/history_policy.rb +35 -0
  72. data/lib/saboteur/reporter/history_record.rb +131 -0
  73. data/lib/saboteur/reporter/html.rb +12 -1
  74. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  75. data/lib/saboteur/reporter/next_steps.rb +30 -0
  76. data/lib/saboteur/reporter/palette.rb +37 -0
  77. data/lib/saboteur/reporter/score_history.rb +135 -0
  78. data/lib/saboteur/reporter/score_table.rb +57 -0
  79. data/lib/saboteur/reporter/stryker.rb +18 -3
  80. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  81. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  82. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  83. data/lib/saboteur/reporter/terminal.rb +140 -79
  84. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  85. data/lib/saboteur/result.rb +44 -6
  86. data/lib/saboteur/selection/coverage_map.rb +119 -13
  87. data/lib/saboteur/selection/git_diff.rb +28 -1
  88. data/lib/saboteur/selection/recorder.rb +55 -13
  89. data/lib/saboteur/subject.rb +40 -4
  90. data/lib/saboteur/subject_finder.rb +62 -12
  91. data/lib/saboteur/version.rb +1 -1
  92. data/lib/saboteur/working_tree.rb +8 -0
  93. data/lib/saboteur.rb +6 -0
  94. metadata +46 -2
@@ -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,32 +13,45 @@ 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 exclude_operators arid
17
+ timeout_factor timeout_min
18
+ max_memory_mb max_processes seed tests whole_file].freeze
16
19
 
17
20
  attr_reader :config, :hooks
21
+ # The specs --guess-tests narrowed the run to, or nil when it did not.
22
+ attr_reader :guessed_tests
18
23
 
19
- def initialize(config, err: $stderr, digest: nil)
24
+ def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [])
20
25
  @config = config
21
26
  @hooks = Hooks.new
22
27
  @diagnostics = Diagnostics.new(err)
23
28
  @root = File.realpath(config.root)
24
29
  @skipped_subjects = {}
25
30
  @replay = Replay.new(digest: digest) if digest
31
+ if capture_evidence && (digest || config["since"] || config["incremental"])
32
+ raise ConfigError, "baseline evidence requires fresh whole-scope execution: no since, replay or incremental"
33
+ end
34
+
35
+ @capture_evidence = capture_evidence
36
+ @evidence_files = evidence_files
37
+ @stable_suite = false
26
38
  end
27
39
 
28
40
  def call
29
41
  Boot.refuse_recursion!
42
+ refuse_fail_fast_evidence
30
43
  started = monotonic
44
+ guess_tests
45
+ announce_operators
46
+ @run_evidence = Baseline::RunEvidence.new(config: config, extra_files: @evidence_files) if @capture_evidence
31
47
  ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
32
- @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
48
+ warn_custom_map
33
49
  boot = Boot.new(config, hooks)
34
50
  begin
35
- integration = boot.load_target
51
+ integration, files, load_lines = boot_target(boot)
36
52
  @rails = boot.rails
37
- files = config.included_files
38
53
  subjects, scope = select_subjects(files)
39
- results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
54
+ results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
40
55
  @diagnostics.warnings.concat(ignore_list.apply(results))
41
56
  build_report(results, subjects, scope, started)
42
57
  ensure
@@ -51,6 +66,10 @@ module Saboteur
51
66
  end
52
67
  end
53
68
 
69
+ def warn_custom_map
70
+ @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
71
+ end
72
+
54
73
  # Stale entries and duplicate literals never fail a run; they warn so the
55
74
  # map does not rot silently. doctor fails on stale keys.
56
75
  def load_custom_map
@@ -67,12 +86,55 @@ module Saboteur
67
86
 
68
87
  private
69
88
 
89
+ # --guess-tests with --since: scope the run to the specs found next to
90
+ # the changed files, as --test would, but only when each changed file
91
+ # has one. Otherwise the whole suite runs, as without the flag.
92
+ def guess_tests
93
+ return unless config["guess_tests"]
94
+
95
+ guess = TestGuess.new(config).call
96
+ message =
97
+ if guess.changed.zero?
98
+ "--guess-tests: no changed source file to guess specs for, so the whole suite runs"
99
+ elsif guess.unmatched.any?
100
+ "--guess-tests: no spec found for #{guess.unmatched.join(', ')}, so the whole suite runs " \
101
+ "(name specs after their source files or pass --test)"
102
+ else
103
+ # The guesses now stand as explicit tests, like --test.
104
+ @config = Config.new(config.to_h.merge("tests" => guess.specs, "guess_tests" => false), root: config.root)
105
+ @guessed_tests = guess.specs
106
+ "--guess-tests: running only #{guess.specs.join(', ')}. A spec that reaches a changed file only " \
107
+ "through another file does not run, so a mutant only it would kill can survive, and a line " \
108
+ "no guessed spec executes is reported as NoCoverage"
109
+ end
110
+ @diagnostics.warn(message)
111
+ @diagnostics.inform(message)
112
+ end
113
+
70
114
  # The run's immutable report, built once the last result is in.
115
+ def announce_operators
116
+ @diagnostics.inform("Operators: #{config.operator_policy['effective'].join(', ')}")
117
+ end
118
+
71
119
  def build_report(results, subjects, scope, started)
72
120
  Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
73
121
  invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
74
122
  review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
75
- diff_scope: scope)
123
+ diff_scope: scope, generation_events: @diagnostics.generation_events,
124
+ omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
125
+ hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
126
+ matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
127
+ operator_policy: config.operator_policy)
128
+ end
129
+
130
+ def complete_evidence(results, subjects)
131
+ return nil unless @run_evidence
132
+
133
+ @run_evidence.plan(subjects, subjects.map { [] }) unless @evidence_planned
134
+ @run_evidence.finish(results: results, stable_suite: @stable_suite, skipped_subjects: @skipped_subjects,
135
+ generation_events: @diagnostics.generation_events, omissions: @diagnostics.omissions,
136
+ discovery_failures: @diagnostics.discovery_failures,
137
+ inline_suppressions: @diagnostics.inline_suppressions)
76
138
  end
77
139
 
78
140
  # The subjects to mutate and, in diff mode, the scope of changed lines.
@@ -86,10 +148,10 @@ module Saboteur
86
148
  [subjects, scope]
87
149
  end
88
150
 
89
- def mutate(subjects, files, integration, scope)
151
+ def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
90
152
  warn_about_unscoped_suite(integration)
91
153
  check_baseline(integration)
92
- map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
154
+ map = CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
93
155
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
94
156
  inform_about_run(subjects, map)
95
157
  cache = result_cache
@@ -104,13 +166,49 @@ module Saboteur
104
166
  # mutant when serial. Pack the heap now that the map and candidates
105
167
  # are retained, to reduce later copying of shared pages.
106
168
  Execution::ForkReadiness.prepare_heap
107
- results = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
108
- cache: cache, on_start: method(:start_worker)).run(runnable_subjects,
109
- runnable_lists)
169
+ results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
110
170
  cache&.save
111
171
  results
112
172
  end
113
173
 
174
+ def run_candidates(subjects, lists, scheduler, cache, ignore_list)
175
+ @diagnostics.start_progress(lists.sum(&:size))
176
+ distribution = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
177
+ cache: cache, on_start: method(:start_worker),
178
+ after_subject: canary_check, stop_after: fail_fast_check(ignore_list))
179
+ distribution.run(subjects, lists).tap { record_early_stop(distribution.unreached) }
180
+ ensure
181
+ @diagnostics.finish_progress
182
+ end
183
+
184
+ # Baseline evidence certifies a whole selection; a run that may stop early
185
+ # cannot.
186
+ def refuse_fail_fast_evidence
187
+ return unless @capture_evidence && config["fail_fast"]
188
+
189
+ raise ConfigError, "fail_fast cannot be used with baseline evidence: it certifies the whole selection"
190
+ end
191
+
192
+ # Subjects that were selected but never ran, with candidates, are part of
193
+ # the report: the verdict covers less than the selection.
194
+ def record_early_stop(unreached)
195
+ return if unreached.empty?
196
+
197
+ @early_stop = EarlyStop.new(reason: "fail_fast", unreached: unreached.map(&:id))
198
+ end
199
+
200
+ # --fail-fast: stop after the first subject that has a survivor the
201
+ # ignore list does not accept. Granularity is one subject.
202
+ def fail_fast_check(ignore_list)
203
+ return nil unless config["fail_fast"]
204
+
205
+ lambda do |results|
206
+ survivor = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
207
+ @diagnostics.inform("--fail-fast: stopping after the first subject with a survivor") if survivor
208
+ survivor
209
+ end
210
+ end
211
+
114
212
  def inform_about_run(subjects, map)
115
213
  @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
116
214
  warn_about_threads
@@ -123,7 +221,11 @@ module Saboteur
123
221
  return unless config["since"] && config["tests"].empty?
124
222
 
125
223
  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."
224
+ "(#{integration.test_files.size} spec files). " \
225
+ "Pass --test to run only the specs you name: faster, but a mutant of a changed method is then killed " \
226
+ "only by those specs, so specs elsewhere that call this code are not run. " \
227
+ "--guess-tests does the same with the specs named after the changed files, " \
228
+ "a guess that can miss specs elsewhere."
127
229
  @diagnostics.warn(message)
128
230
  @diagnostics.inform(message)
129
231
  end
@@ -131,21 +233,31 @@ module Saboteur
131
233
  def check_baseline(integration)
132
234
  BaselineGuard.new(config: config, integration: integration, hooks: hooks,
133
235
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
236
+ @stable_suite = true
134
237
  end
135
238
 
136
239
  def scheduler_for(map, integration, cache)
137
- reevaluator = Execution::Reevaluator.new(config["reeval"])
240
+ reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
138
241
  runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
139
- supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"])
242
+ supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"],
243
+ sandbox: sandbox_hook)
140
244
  Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
141
245
  cache: cache)
142
246
  end
143
247
 
248
+ # The sandbox hook for each mutant child (#152); the policy and its
249
+ # one-time note live in Engine::SandboxPolicy.
250
+ def sandbox_hook
251
+ @sandbox_policy ||= SandboxPolicy.new(config: config, root: @root, diagnostics: @diagnostics)
252
+ @sandbox_policy.hook
253
+ end
254
+
144
255
  def result_cache
145
256
  return nil if @replay
146
257
  return nil unless config["incremental"]
147
258
 
148
- inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] })
259
+ inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }
260
+ .merge("operator_policy" => config.operator_policy))
149
261
  ResultCache.new(path: File.join(config.path("cache_dir"), "results.json"), root: @root, inputs: inputs,
150
262
  suite_digest: SuiteFiles.new(config).digest)
151
263
  end
@@ -165,19 +277,43 @@ module Saboteur
165
277
  end
166
278
 
167
279
  # Each subject's candidates, narrowed to changed lines in diff mode.
280
+ # The hazard guard is off only when the user explicitly asks for the
281
+ # dangerous mutants (issue #102); the canary then still watches the tree.
282
+ def hazard_guard
283
+ return nil if config["allow_hazardous"]
284
+
285
+ MutationSource::HazardGuard.new(config["hazard"])
286
+ end
287
+
288
+ # Checks the tree after each subject's mutants; a vanished file means a
289
+ # mutant deleted it, so abort with the most recent mutant's digest.
290
+ def canary_check
291
+ canary = Canary.new(@root, cache_dir: config["cache_dir"])
292
+ lambda do |subject, results|
293
+ last = results.reject(&:reused?).last || results.last
294
+ digest = last&.mutation&.digest(subject_start: subject.start_byte,
295
+ original_source: subject.file_source)
296
+ canary.check!(subject: subject, digest: digest)
297
+ end
298
+ end
299
+
168
300
  def candidate_lists(subjects, scope, map)
169
301
  return [@replay_candidate] if @replay
170
302
 
171
- candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
303
+ candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
172
304
  selection = CandidateSelection.new(candidates: candidates, map: map,
173
305
  limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
174
306
  subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
175
307
  @skipped_subjects = selection.skipped_subjects
308
+ if @run_evidence
309
+ @run_evidence.plan(subjects, subjects.map { |subject| selection.selected.fetch(subject) })
310
+ @evidence_planned = true
311
+ end
176
312
  end
177
313
  end
178
314
 
179
315
  def replay_subject(subjects, scope)
180
- candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
316
+ candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
181
317
  subject, candidate = @replay.select(subjects, candidates, scope: scope)
182
318
  @replay_candidate = [candidate]
183
319
  [[subject], nil]
@@ -210,6 +346,47 @@ module Saboteur
210
346
  @diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
211
347
  end
212
348
 
349
+ # The lines that ran while the app and spec files loaded: the executable
350
+ # counts from its first line, so this snapshot holds the whole boot.
351
+ # Read here, in the parent: a forked child does not inherit Coverage
352
+ # counters. They become the map's load lines, which tell ran-at-load
353
+ # mutants apart from uncovered ones.
354
+ def capture_load_lines(files)
355
+ return {} unless Coverage.state == :suspended
356
+
357
+ wanted = files.to_set { |file| Selection::CoverageMap.canonical(file) }
358
+ Coverage.result(stop: false, clear: false).each_with_object({}) do |(path, data), hits|
359
+ file = Selection::CoverageMap.canonical(path)
360
+ next unless wanted.include?(file)
361
+
362
+ lines = data[:lines].each_with_index.filter_map { |count, index| index + 1 if count&.positive? }
363
+ lines -= uncalled_shared_lines(data)
364
+ hits[file] = lines unless lines.empty?
365
+ end
366
+ end
367
+
368
+ # A one-line or endless definition executes its def line even when its
369
+ # body is never called. If any sibling on that physical line is uncalled,
370
+ # discard its ambiguous line evidence rather than crediting every body.
371
+ def uncalled_shared_lines(data)
372
+ shared = data.fetch(:methods, {}).select { |(_owner, _name, first, _column, last, *), _| first == last }
373
+ calls = shared.group_by { |(_owner, _name, first, *), _count| first }
374
+ calls.filter_map { |line, entries| line if entries.any? { |_key, count| count.zero? } }
375
+ end
376
+
377
+ # Loads the target and stops coverage, returning what the rest of the
378
+ # run needs from that boot.
379
+ def boot_target(boot)
380
+ integration = boot.load_target
381
+ files = config.included_files
382
+ [integration, files, suspend_coverage(files)]
383
+ end
384
+
385
+ def suspend_coverage(files)
386
+ Coverage.suspend if Coverage.state == :running
387
+ capture_load_lines(files)
388
+ end
389
+
213
390
  def monotonic
214
391
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
215
392
  end
@@ -219,15 +396,20 @@ end
219
396
  require_relative "engine/diagnostics"
220
397
  require_relative "engine/boot"
221
398
  require_relative "engine/subject_selection"
399
+ require_relative "engine/unchanged_syntax"
222
400
  require_relative "engine/suite_files"
401
+ require_relative "engine/test_guess"
402
+ require_relative "engine/sandbox_policy"
223
403
  require_relative "engine/coverage_store"
224
404
  require_relative "engine/baseline_guard"
405
+ require_relative "engine/bytecode_equivalence"
225
406
  require_relative "engine/candidates"
226
407
  require_relative "engine/candidate_selection"
227
408
  require_relative "engine/diff_scope"
228
409
  require_relative "engine/result_cache"
229
410
  require_relative "engine/verdict_check"
230
411
  require_relative "engine/scheduler"
412
+ require_relative "engine/canary"
231
413
  require_relative "engine/distribution"
232
414
 
233
415
  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,19 +12,60 @@ 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)
@@ -32,7 +73,13 @@ module Saboteur
32
73
  copy_visibility = visibility_of(owner.singleton_class, subject.name) if module_function
33
74
  aliases = aliases_of(target, subject.name)
34
75
 
35
- @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
36
83
 
37
84
  restore(owner, target, subject.name, visibility, copy_visibility)
38
85
  repoint(target, subject.name, aliases)
@@ -90,23 +137,38 @@ module Saboteur
90
137
  owner.singleton_class.instance_method(subject.name).source_location
91
138
  end
92
139
 
93
- def eval_in_owner(owner, subject, text)
94
- 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)
95
155
  eval_target = subject.sclass ? owner.singleton_class : owner
96
156
  eval_target.class_eval(code, subject.file, line)
97
157
  end
98
158
 
99
- def eval_nested(subject, text)
159
+ def eval_nested(subject, text, extra_lines)
100
160
  headers = subject.nesting.map(&:header)
101
161
  wrapped = "#{headers.join('; ')}; #{text}; #{(['end'] * headers.size).join('; ')}"
102
- code, line = with_magic_comment(subject, wrapped)
162
+ code, line = with_magic_comment(subject, wrapped, extra_lines)
103
163
  TOPLEVEL_BINDING.eval(code, subject.file, line)
104
164
  end
105
165
 
106
- def with_magic_comment(subject, code)
107
- 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
108
170
 
109
- [FROZEN_HEADER + code, subject.start_line - 1]
171
+ [FROZEN_HEADER + code, subject.start_line - extra_lines - 1]
110
172
  end
111
173
 
112
174
  # Methods defined with alias/alias_method copy the old body. Callers