saboteur 0.4.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/README.md +393 -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 +105 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +114 -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 +101 -121
  19. data/lib/saboteur/config.rb +76 -6
  20. data/lib/saboteur/doctor.rb +40 -8
  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 +59 -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 +6 -4
  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 +52 -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 +192 -18
  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/gates.rb +83 -0
  48. data/lib/saboteur/help.rb +31 -7
  49. data/lib/saboteur/ignore_list.rb +5 -0
  50. data/lib/saboteur/inline_suppression.rb +65 -0
  51. data/lib/saboteur/integration/base.rb +1 -0
  52. data/lib/saboteur/integration/focus_guard.rb +83 -0
  53. data/lib/saboteur/integration/rspec.rb +21 -1
  54. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  55. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  56. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  57. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  58. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  59. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  60. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  61. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  62. data/lib/saboteur/preview.rb +106 -0
  63. data/lib/saboteur/reporter/agent_commands.rb +7 -1
  64. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  65. data/lib/saboteur/reporter/agent_summary.rb +11 -5
  66. data/lib/saboteur/reporter/findings.rb +4 -19
  67. data/lib/saboteur/reporter/history_paths.rb +38 -0
  68. data/lib/saboteur/reporter/history_record.rb +132 -0
  69. data/lib/saboteur/reporter/html.rb +12 -1
  70. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  71. data/lib/saboteur/reporter/next_steps.rb +30 -0
  72. data/lib/saboteur/reporter/palette.rb +37 -0
  73. data/lib/saboteur/reporter/score_history.rb +123 -0
  74. data/lib/saboteur/reporter/score_table.rb +57 -0
  75. data/lib/saboteur/reporter/stryker.rb +15 -2
  76. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  77. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  78. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  79. data/lib/saboteur/reporter/terminal.rb +131 -79
  80. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  81. data/lib/saboteur/result.rb +41 -6
  82. data/lib/saboteur/selection/coverage_map.rb +119 -13
  83. data/lib/saboteur/selection/git_diff.rb +28 -1
  84. data/lib/saboteur/selection/recorder.rb +55 -13
  85. data/lib/saboteur/subject.rb +40 -4
  86. data/lib/saboteur/subject_finder.rb +62 -12
  87. data/lib/saboteur/version.rb +1 -1
  88. data/lib/saboteur/working_tree.rb +8 -0
  89. data/lib/saboteur.rb +6 -0
  90. metadata +43 -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,43 @@ 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"))
32
- @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
46
+ warn_custom_map
33
47
  boot = Boot.new(config, hooks)
34
48
  begin
35
- integration = boot.load_target
49
+ integration, files, load_lines = boot_target(boot)
36
50
  @rails = boot.rails
37
- files = config.included_files
38
51
  subjects, scope = select_subjects(files)
39
- results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
52
+ results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
40
53
  @diagnostics.warnings.concat(ignore_list.apply(results))
41
54
  build_report(results, subjects, scope, started)
42
55
  ensure
@@ -51,6 +64,10 @@ module Saboteur
51
64
  end
52
65
  end
53
66
 
67
+ def warn_custom_map
68
+ @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
69
+ end
70
+
54
71
  # Stale entries and duplicate literals never fail a run; they warn so the
55
72
  # map does not rot silently. doctor fails on stale keys.
56
73
  def load_custom_map
@@ -67,12 +84,50 @@ module Saboteur
67
84
 
68
85
  private
69
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
+
70
112
  # The run's immutable report, built once the last result is in.
71
113
  def build_report(results, subjects, scope, started)
72
114
  Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
73
115
  invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
74
116
  review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
75
- diff_scope: scope)
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)
121
+ end
122
+
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)
76
131
  end
77
132
 
78
133
  # The subjects to mutate and, in diff mode, the scope of changed lines.
@@ -86,10 +141,10 @@ module Saboteur
86
141
  [subjects, scope]
87
142
  end
88
143
 
89
- def mutate(subjects, files, integration, scope)
144
+ def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
90
145
  warn_about_unscoped_suite(integration)
91
146
  check_baseline(integration)
92
- map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
147
+ map = CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
93
148
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
94
149
  inform_about_run(subjects, map)
95
150
  cache = result_cache
@@ -104,13 +159,49 @@ module Saboteur
104
159
  # mutant when serial. Pack the heap now that the map and candidates
105
160
  # are retained, to reduce later copying of shared pages.
106
161
  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)
162
+ results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
110
163
  cache&.save
111
164
  results
112
165
  end
113
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
+
114
205
  def inform_about_run(subjects, map)
115
206
  @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
116
207
  warn_about_threads
@@ -123,7 +214,11 @@ module Saboteur
123
214
  return unless config["since"] && config["tests"].empty?
124
215
 
125
216
  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."
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."
127
222
  @diagnostics.warn(message)
128
223
  @diagnostics.inform(message)
129
224
  end
@@ -131,16 +226,25 @@ module Saboteur
131
226
  def check_baseline(integration)
132
227
  BaselineGuard.new(config: config, integration: integration, hooks: hooks,
133
228
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
229
+ @stable_suite = true
134
230
  end
135
231
 
136
232
  def scheduler_for(map, integration, cache)
137
- reevaluator = Execution::Reevaluator.new(config["reeval"])
233
+ reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
138
234
  runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
139
- 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)
140
237
  Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
141
238
  cache: cache)
142
239
  end
143
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
+
144
248
  def result_cache
145
249
  return nil if @replay
146
250
  return nil unless config["incremental"]
@@ -165,19 +269,43 @@ module Saboteur
165
269
  end
166
270
 
167
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
+
168
292
  def candidate_lists(subjects, scope, map)
169
293
  return [@replay_candidate] if @replay
170
294
 
171
- candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
295
+ candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
172
296
  selection = CandidateSelection.new(candidates: candidates, map: map,
173
297
  limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
174
298
  subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
175
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
176
304
  end
177
305
  end
178
306
 
179
307
  def replay_subject(subjects, scope)
180
- candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
308
+ candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
181
309
  subject, candidate = @replay.select(subjects, candidates, scope: scope)
182
310
  @replay_candidate = [candidate]
183
311
  [[subject], nil]
@@ -210,6 +338,47 @@ module Saboteur
210
338
  @diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
211
339
  end
212
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)
380
+ end
381
+
213
382
  def monotonic
214
383
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
215
384
  end
@@ -219,15 +388,20 @@ end
219
388
  require_relative "engine/diagnostics"
220
389
  require_relative "engine/boot"
221
390
  require_relative "engine/subject_selection"
391
+ require_relative "engine/unchanged_syntax"
222
392
  require_relative "engine/suite_files"
393
+ require_relative "engine/test_guess"
394
+ require_relative "engine/sandbox_policy"
223
395
  require_relative "engine/coverage_store"
224
396
  require_relative "engine/baseline_guard"
397
+ require_relative "engine/bytecode_equivalence"
225
398
  require_relative "engine/candidates"
226
399
  require_relative "engine/candidate_selection"
227
400
  require_relative "engine/diff_scope"
228
401
  require_relative "engine/result_cache"
229
402
  require_relative "engine/verdict_check"
230
403
  require_relative "engine/scheduler"
404
+ require_relative "engine/canary"
231
405
  require_relative "engine/distribution"
232
406
 
233
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,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