saboteur 0.12.1 → 0.12.3

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +18 -3
  4. data/lib/saboteur/baseline/command.rb +6 -1
  5. data/lib/saboteur/baseline/comparison.rb +8 -0
  6. data/lib/saboteur/baseline/completeness.rb +14 -4
  7. data/lib/saboteur/baseline/evidence.rb +8 -1
  8. data/lib/saboteur/baseline/run_evidence.rb +10 -1
  9. data/lib/saboteur/baseline/workflow.rb +17 -5
  10. data/lib/saboteur/cli/color_option.rb +26 -0
  11. data/lib/saboteur/cli/doctor_command.rb +1 -0
  12. data/lib/saboteur/cli/exit_status.rb +4 -3
  13. data/lib/saboteur/cli/explain_command.rb +2 -1
  14. data/lib/saboteur/cli/failure_output.rb +13 -2
  15. data/lib/saboteur/cli/help_command.rb +5 -0
  16. data/lib/saboteur/cli/history_command.rb +2 -0
  17. data/lib/saboteur/cli/output_options.rb +1 -5
  18. data/lib/saboteur/cli/run_command.rb +1 -1
  19. data/lib/saboteur/cli/run_reporting.rb +1 -9
  20. data/lib/saboteur/cli/run_validation.rb +0 -1
  21. data/lib/saboteur/cli/survivors_command.rb +10 -6
  22. data/lib/saboteur/cli/whole_suite_map_lookup.rb +33 -0
  23. data/lib/saboteur/cli.rb +6 -2
  24. data/lib/saboteur/engine/coverage_store.rb +7 -4
  25. data/lib/saboteur/engine/diagnostics.rb +7 -0
  26. data/lib/saboteur/engine/pipelines.rb +9 -2
  27. data/lib/saboteur/engine/result_cache.rb +8 -5
  28. data/lib/saboteur/engine/scheduler.rb +3 -2
  29. data/lib/saboteur/engine/survivor_confirmation.rb +75 -11
  30. data/lib/saboteur/engine/test_guess.rb +2 -1
  31. data/lib/saboteur/engine.rb +34 -16
  32. data/lib/saboteur/execution/runner.rb +14 -4
  33. data/lib/saboteur/execution/site_probe.rb +161 -0
  34. data/lib/saboteur/gates.rb +1 -1
  35. data/lib/saboteur/integration/rspec.rb +30 -0
  36. data/lib/saboteur/reporter/default_findings.rb +1 -1
  37. data/lib/saboteur/reporter/findings.rb +2 -1
  38. data/lib/saboteur/reporter/gap_kinds.rb +18 -0
  39. data/lib/saboteur/reporter/next_steps.rb +3 -1
  40. data/lib/saboteur/reporter/review_comments.rb +4 -1
  41. data/lib/saboteur/reporter/score_table.rb +6 -11
  42. data/lib/saboteur/reporter/stryker.rb +12 -2
  43. data/lib/saboteur/reporter/survivor_classification.rb +53 -6
  44. data/lib/saboteur/reporter/survivor_hints.rb +13 -9
  45. data/lib/saboteur/reporter/survivor_list.rb +6 -6
  46. data/lib/saboteur/reporter/terminal.rb +4 -2
  47. data/lib/saboteur/reporter/unconfirmed_section.rb +44 -0
  48. data/lib/saboteur/result.rb +46 -4
  49. data/lib/saboteur/selection/coverage_map.rb +11 -1
  50. data/lib/saboteur/version.rb +1 -1
  51. data/lib/saboteur.rb +5 -0
  52. metadata +5 -1
@@ -18,7 +18,10 @@ module Saboteur
18
18
  CACHE_INPUTS = %w[integration require rspec_args rails tests coverage_recorder].freeze
19
19
  # Names the freshest whole-suite map build; read as an untrusted hint:
20
20
  # only a bare 64-hex key joins the cache dir (#427).
21
- WHOLE_SUITE_POINTER = "whole-suite-map"
21
+ # Renamed from "whole-suite-map" in 0.12.2: older versions also wrote
22
+ # the pointer for maps built from inferred specs, which are not whole
23
+ # suites, so their pointers are never read (#1286).
24
+ WHOLE_SUITE_POINTER = "whole-suite-map-v2"
22
25
  LOCKFILES = %w[Gemfile.lock gems.locked].freeze
23
26
  LOG = "coverage-build.log"
24
27
  # RSpec rewrites this on every run (example_status_persistence_file_path),
@@ -65,7 +68,7 @@ module Saboteur
65
68
  return serve_cached(path, cached, files, preloaded) if cached
66
69
 
67
70
  note = miss_note(files)
68
- @diagnostics.inform(note.message(scoped: !@config["tests"].empty?)) if @config["cache"]
71
+ @diagnostics.notice(note.message(scoped: !@config["tests"].empty?)) if @config["cache"]
69
72
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
73
  map = fresh_map(files, note, preloaded)
71
74
  build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
@@ -237,7 +240,7 @@ module Saboteur
237
240
  return build_parts(files, parts) if parts.size > 1
238
241
 
239
242
  scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
240
- @diagnostics.inform("building coverage map (runs #{scope} once)")
243
+ @diagnostics.notice("building coverage map (runs #{scope} once)")
241
244
  # The suite child forked next inherits this heap; pack it first, to
242
245
  # reduce later copying of shared pages.
243
246
  Execution::ForkReadiness.prepare_heap
@@ -267,7 +270,7 @@ module Saboteur
267
270
  total, counts = usage_checked { check_parts_cover_suite(parts) }
268
271
  saved = saved_parts(files, parts.size, counts)
269
272
  todo = saved.each_index.select { |index| saved[index].nil? }
270
- @diagnostics.inform(parts_note(parts.size, todo.size))
273
+ @diagnostics.notice(parts_note(parts.size, todo.size))
271
274
  built = todo.empty? ? [] : run_parts(files, parts, todo)
272
275
  todo.zip(built) { |index, map| saved[index] = map }
273
276
  map = combine_saved(saved, total, files, parts.size)
@@ -187,6 +187,13 @@ module Saboteur
187
187
  @err.puts("saboteur: #{Text.display_safe_lines(message)}")
188
188
  end
189
189
 
190
+ # First-run explanations (#1264): the --quiet contract is findings,
191
+ # score and verdict, so these stay off stderr under it and print
192
+ # exactly as before otherwise.
193
+ def notice(message)
194
+ inform(message) unless @quiet
195
+ end
196
+
190
197
  # Chatter the default view folds into the quiet-start lines;
191
198
  # --verbose prints it byte-for-byte as before.
192
199
  def detail(message)
@@ -104,9 +104,16 @@ module Saboteur
104
104
  # "Full run" for ignore staleness (#1206): no --since diff, no --subject
105
105
  # filter, and no narrowed include (explicit file arguments replace the
106
106
  # configured include) - only then was every in-scope subject examined, so
107
- # an entry whose subject never appears really points at gone code.
107
+ # an entry whose subject never appears really points at gone code. The
108
+ # include nobody set counts as unscoped whether it is the generic
109
+ # default or the Rails one a config-less app gets (#1255).
108
110
  def unscoped_full_run?
109
- !config["since"] && config["subjects"].empty? && config["include"] == Config::DEFAULTS["include"]
111
+ !config["since"] && config["subjects"].empty? && default_include?
112
+ end
113
+
114
+ def default_include?
115
+ config["include"] == Config::DEFAULTS["include"] ||
116
+ (config["include"] == Config::RAILS_INCLUDE && !config.explicit?("include"))
110
117
  end
111
118
 
112
119
  def external_command? = !config["test_command"].to_s.empty?
@@ -26,7 +26,9 @@ module Saboteur
26
26
  # every source file is watched instead: reuse turns conservative, never
27
27
  # stale.
28
28
  class ResultCache
29
- FORMAT = 1
29
+ # 2: entries carry value_differed (#1290); a version 1 file is dropped so
30
+ # no cached survivor reads as "never differed" without having been observed.
31
+ FORMAT = 2
30
32
  MAX_BYTES = 64 * 1024 * 1024
31
33
  STORED = %i[killed survived timeout].freeze
32
34
  LOCKFILES = %w[Gemfile.lock gems.locked].freeze
@@ -62,13 +64,13 @@ module Saboteur
62
64
  end
63
65
 
64
66
  # The stored verdict as a hash (status, reason, killed_by, kill_cause,
65
- # runtime), or nil when it may no longer hold.
67
+ # runtime, value_differed), or nil when it may no longer hold.
66
68
  def lookup(subject, mutation, tests)
67
69
  entry = @entries[key(subject, mutation)]
68
70
  return nil unless entry && entry["fingerprint"] == fingerprint(subject, tests)
69
71
 
70
72
  { status: entry["status"].to_sym, reason: entry["reason"], killed_by: entry["killed_by"],
71
- kill_cause: entry["kill_cause"], runtime: entry["runtime"] }
73
+ kill_cause: entry["kill_cause"], runtime: entry["runtime"], value_differed: entry["value_differed"] }
72
74
  end
73
75
 
74
76
  def record(result)
@@ -77,7 +79,7 @@ module Saboteur
77
79
  @entries[key(result.subject, result.mutation)] = {
78
80
  "fingerprint" => fingerprint(result.subject, result.covered_by), "status" => result.status.to_s,
79
81
  "reason" => result.reason, "killed_by" => result.killed_by, "kill_cause" => result.kill_cause,
80
- "runtime" => result.runtime
82
+ "runtime" => result.runtime, "value_differed" => result.value_differed
81
83
  }
82
84
  checkpoint if @clock.call - @last_saved >= @save_interval
83
85
  end
@@ -154,7 +156,8 @@ module Saboteur
154
156
  def typed?(entry)
155
157
  STORED.map(&:to_s).include?(entry["status"]) && (entry["runtime"].nil? || entry["runtime"].is_a?(Numeric)) &&
156
158
  %w[reason kill_cause].all? { |key| entry[key].nil? || entry[key].is_a?(String) } &&
157
- entry["killed_by"].is_a?(Array) && entry["killed_by"].all?(String)
159
+ entry["killed_by"].is_a?(Array) && entry["killed_by"].all?(String) &&
160
+ [nil, true, false].include?(entry["value_differed"])
158
161
  end
159
162
 
160
163
  def key(subject, mutation)
@@ -235,7 +235,8 @@ module Saboteur
235
235
 
236
236
  MutantResult.new(subject: plan.subject, mutation: mutation, status: hit[:status], mutated_source: text,
237
237
  reason: hit[:reason], runtime: hit[:runtime], killed_by: hit[:killed_by],
238
- covered_by: tests, kill_cause: hit[:kill_cause], reused: true)
238
+ covered_by: tests, kill_cause: hit[:kill_cause], reused: true,
239
+ value_differed: hit[:value_differed])
239
240
  end
240
241
 
241
242
  def execute(plan, mutation, text)
@@ -247,7 +248,7 @@ module Saboteur
247
248
  result = MutantResult.new(subject: plan.subject, mutation: mutation, status: outcome.status,
248
249
  mutated_source: text, reason: outcome.message, runtime: outcome.runtime,
249
250
  killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause,
250
- executed: outcome.executed_ids)
251
+ executed: outcome.executed_ids, value_differed: outcome.value_differed)
251
252
  outcome.failed_ids.each { |id| plan.killers[id] += 1 }
252
253
  note_leftovers(plan.subject, "#{mutation.operator} #{result.digest}", outcome)
253
254
  @diagnostics.progress(result)
@@ -15,13 +15,14 @@ module Saboteur
15
15
  # confirmation_budget caps the whole pass.
16
16
  MAX_SPECS_PER_SURVIVOR = 8
17
17
 
18
- def initialize(config:, map:, scheduler:, diagnostics:, budget_exceeded:, cache: nil)
18
+ def initialize(config:, map:, scheduler:, diagnostics:, budget_exceeded:, cache: nil, load_specs: ->(_files) {})
19
19
  @config = config
20
20
  @map = map
21
21
  @scheduler = scheduler
22
22
  @diagnostics = diagnostics
23
23
  @budget_exceeded = budget_exceeded
24
24
  @cache = cache
25
+ @load_specs = load_specs
25
26
  end
26
27
 
27
28
  def call(results, ignore_list:, early_stop: nil)
@@ -33,19 +34,60 @@ module Saboteur
33
34
  "stopped early (#{early_stop.reason})")
34
35
  end
35
36
  @remaining = @config["confirmation_budget"]
37
+ @map_silent = 0
36
38
  results.map { |result| survivors.include?(result) ? confirm(result) : result }
37
39
  .tap { |confirmed| summarize(confirmed) }
40
+ .tap { warn_map_silent }
38
41
  end
39
42
 
40
43
  private
41
44
 
45
+ # A crash while confirming one survivor downgrades it to unconfirmed
46
+ # with one warning; it never ends the run (#1140).
42
47
  def confirm(result)
48
+ confirm_checked(result)
49
+ rescue RefusedSpecPath => e
50
+ warn_refused(e)
51
+ result.with_confirmation("unconfirmed_map(a covering spec from the whole-suite map was refused)")
52
+ rescue StandardError => e
53
+ warn_failed(result, e)
54
+ unconfirmed(result, "confirmation failed: #{e.class}")
55
+ end
56
+
57
+ # The map named a spec that is not a spec file inside the project: the
58
+ # map cannot be trusted, so its survivors stay counted (#1286).
59
+ def warn_refused(error)
60
+ return if @warned_refused
61
+
62
+ @warned_refused = true
63
+ @diagnostics.warn("survivor confirmation refused the whole-suite coverage map: " \
64
+ "#{error.message.lines.first.strip}; its survivors stay counted; " \
65
+ "run `saboteur run --all` to rebuild it")
66
+ end
67
+
68
+ # One line per run, however many survivors hit the same failure.
69
+ def warn_failed(result, error)
70
+ return if @warned_failure
71
+
72
+ @warned_failure = true
73
+ @diagnostics.warn("survivor confirmation failed for #{result.subject.id} #{result.digest} " \
74
+ "(#{error.class}: #{error.message.lines.map(&:strip).reject(&:empty?).first}); " \
75
+ "its survivors stay unconfirmed, outside the score")
76
+ end
77
+
78
+ def confirm_checked(result)
43
79
  return unconfirmed(result, "the confirmation budget is spent") if @remaining <= 0
44
80
  return unconfirmed(result, "the --max-duration budget is spent") if @budget_exceeded.call
45
81
 
46
- tests = unrun_covering_tests(result)
47
- return unconfirmed(result, "no covering tests") if tests.empty?
82
+ # Nothing in the whole-suite map covers the edited lines (the tree
83
+ # drifted since the map was built): the map cannot confirm anything.
84
+ covering = covering_tests(result)
85
+ return map_silent(result) if covering.empty?
48
86
 
87
+ tests = unrun_covering_tests(result, covering)
88
+ return result.with_confirmation("confirmed") if tests.empty?
89
+
90
+ load_unselected_specs(tests)
49
91
  outcome = run_confirmation(result, tests)
50
92
  @remaining -= outcome.runtime || 0
51
93
  case outcome.status
@@ -58,8 +100,14 @@ module Saboteur
58
100
  # The whole-suite map's covering tests for the mutant's edited lines
59
101
  # (branch evidence intersected, mirroring the scheduler), minus the
60
102
  # tests the mutant already ran, fastest first, capped.
61
- def unrun_covering_tests(result)
62
- fastest_first(covering_tests(result) - (result.executed || result.covered_by)).first(MAX_SPECS_PER_SURVIVOR)
103
+ def unrun_covering_tests(result, covering)
104
+ fastest_first(covering - (result.executed || result.covered_by)).first(MAX_SPECS_PER_SURVIVOR)
105
+ end
106
+
107
+ # The covering specs may live in files this run never loaded; the
108
+ # forked confirmation child can only run examples already in its world.
109
+ def load_unselected_specs(tests)
110
+ @load_specs.call(tests.map { |id| id.sub(/\[[\d:]+\]\z/, "") })
63
111
  end
64
112
 
65
113
  def covering_tests(result)
@@ -86,11 +134,12 @@ module Saboteur
86
134
  # becomes an error; a check the budgets never let run comes back
87
135
  # unverified - outside the score and never cached.
88
136
  def checked_kill(result, tests, outcome)
89
- kill = result.with_confirmation("confirmed", status: :killed, reason: outcome.message,
90
- killed_by: outcome.failed_ids, covered_by: tests,
91
- kill_cause: outcome.kill_cause,
92
- runtime: (result.runtime || 0) + (outcome.runtime || 0),
93
- executed: (result.executed || []) + outcome.executed_ids)
137
+ kill = result.with_confirmation(
138
+ "killed_by_confirmation", status: :killed, reason: outcome.message, killed_by: outcome.failed_ids,
139
+ covered_by: tests, kill_cause: outcome.kill_cause,
140
+ runtime: (result.runtime || 0) + (outcome.runtime || 0),
141
+ executed: (result.executed || []) + outcome.executed_ids
142
+ )
94
143
  checked = VerdictCheck.new(run_unchanged: unchanged_runner(result.subject),
95
144
  baseline_timeout: @config["baseline_timeout"]).call([kill]).first
96
145
  return checked unless checked.status == :killed
@@ -108,7 +157,7 @@ module Saboteur
108
157
  # (#1079). ResultCache#record replaces the subject+mutation entry, so
109
158
  # the stale survivor verdict cannot win a later lookup.
110
159
  def record_kill(result, kill)
111
- @cache&.record(kill.with_confirmation("confirmed", covered_by: covering_tests(result)))
160
+ @cache&.record(kill.with_confirmation("killed_by_confirmation", covered_by: covering_tests(result)))
112
161
  end
113
162
 
114
163
  # The unchanged-method re-run VerdictCheck checks a kill against, in
@@ -129,6 +178,13 @@ module Saboteur
129
178
  end
130
179
  end
131
180
 
181
+ # The map says nothing about the edited lines (the tree drifted since it
182
+ # was built): the survivor stays counted and the pass says so once.
183
+ def map_silent(result)
184
+ @map_silent += 1
185
+ result.with_confirmation("unconfirmed_map(no covering tests in the whole-suite map)")
186
+ end
187
+
132
188
  def unconfirmed(result, reason)
133
189
  result.with_confirmation("unconfirmed(#{reason})")
134
190
  end
@@ -148,6 +204,14 @@ module Saboteur
148
204
  durations.empty? ? nil : durations.sum / durations.size
149
205
  end
150
206
 
207
+ def warn_map_silent
208
+ return if @map_silent.zero?
209
+
210
+ @diagnostics.warn("survivor confirmation: the whole-suite coverage map covers none of the edited lines of " \
211
+ "#{@map_silent} survivor#{'s' unless @map_silent == 1}, so they stay counted; " \
212
+ "run `saboteur run --all` to rebuild it")
213
+ end
214
+
151
215
  def summarize(results)
152
216
  counts = results.filter_map(&:confirmation).tally
153
217
  return if counts.empty?
@@ -116,7 +116,8 @@ module Saboteur
116
116
  # Why the subjectless files needed no spec.
117
117
  def subjectless_message(since:)
118
118
  detail = since ? "no changed mutation subjects" : "no mutation subjects"
119
- "inferred specs: no spec is required for #{names(subjectless)}: they have #{detail}"
119
+ who = subjectless.size == 1 ? "it has" : "they have"
120
+ "inferred specs: no spec is required for #{names(subjectless)}: #{who} #{detail}"
120
121
  end
121
122
 
122
123
  # The per-file warning when the run proceeds with the mapped
@@ -31,11 +31,6 @@ module Saboteur
31
31
  include GatedRun
32
32
  include SelectionAnnouncement
33
33
 
34
- # Confirmation only guards a partial coverage map, so it is silent unless
35
- # the user opted into partial_coverage = use (#1228).
36
- CONFIRMATION_SKIPPED = "survivor confirmation skipped: partial_coverage = use applies only to a cached " \
37
- "whole-suite run (no --test, one coverage part)"
38
-
39
34
  # Everything besides sources and tests that can change a verdict.
40
35
  VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
41
36
  timeout_factor timeout_min
@@ -130,6 +125,12 @@ module Saboteur
130
125
  @coverage_store&.whole_suite_map
131
126
  end
132
127
 
128
+ # True when this run did not build a whole-suite map: a diff, named
129
+ # subjects, an explicit --test or inferred specs (#1286).
130
+ def scoped_selection?
131
+ !whole_suite_run?
132
+ end
133
+
133
134
  # False when caching is off, where no coverage build can ever leave a
134
135
  # whole-suite map to consult, so advising one would be wrong (review C1).
135
136
  def whole_suite_map_lookup?
@@ -292,15 +293,21 @@ module Saboteur
292
293
  confirm_survivors(run_candidates(subjects, lists, scheduler, cache, ignore_list), scheduler, cache, ignore_list)
293
294
  end
294
295
 
296
+ # Confirm each live survivor against the whole-suite coverage map (#1286):
297
+ # the covering specs the run's own selection never loaded get one budgeted
298
+ # run. Scoped runs (--since, --subject, pr) are the point, so nothing here
299
+ # depends on partial_coverage or on the run being unscoped. Without a
300
+ # whole-suite map there is nothing to confirm against; the run says so
301
+ # elsewhere (survivors unconfirmed: no whole-suite coverage map).
295
302
  def confirm_survivors(results, scheduler, cache, ignore_list)
296
- unless CoveragePartial.use?(config, config["coverage_parts"])
297
- live = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
298
- @diagnostics.inform(CONFIRMATION_SKIPPED) if live && config["partial_coverage"] == "use"
299
- return results
300
- end
303
+ return results if results.none? { |result| result.survived? && !ignore_list.accepts?(result) }
301
304
 
302
- SurvivorConfirmation.new(config: config, map: scheduler.map, scheduler: scheduler, diagnostics: @diagnostics,
303
- budget_exceeded: method(:budget_exceeded?), cache: cache)
305
+ whole = whole_suite_coverage_map
306
+ return results if whole.nil?
307
+
308
+ SurvivorConfirmation.new(config: config, map: whole, scheduler: scheduler, diagnostics: @diagnostics,
309
+ budget_exceeded: method(:budget_exceeded?), cache: cache,
310
+ load_specs: ->(files) { scheduler.runner.integration.load_spec_files(files) })
304
311
  .call(results, ignore_list: ignore_list, early_stop: @early_stop)
305
312
  end
306
313
 
@@ -350,10 +357,19 @@ module Saboteur
350
357
  # baseline's own chatter (demoted to --verbose) would start.
351
358
  def announce_quiet_start(store, files)
352
359
  @map_cached = store.reusable?(files)
353
- @diagnostics.quiet_start_header(scope: config["since"] ? "--since #{config['since']}" : "whole project",
360
+ @diagnostics.quiet_start_header(scope: quiet_scope,
354
361
  seed: config["seed"], operators: config.operator_policy["effective"].size)
355
362
  end
356
363
 
364
+ # Names what the run covers: the diff, the --subject filter, or the
365
+ # whole project (P6) - a --subject run is not "whole project".
366
+ def quiet_scope
367
+ parts = []
368
+ parts << "--since #{config['since']}" if config["since"]
369
+ parts << "--subject #{config['subjects'].join(', ')}" if config["subjects"].any?
370
+ parts.empty? ? "whole project" : parts.join(", ")
371
+ end
372
+
357
373
  def inform_about_run(subjects, map)
358
374
  if @diagnostics.quiet_start && !@diagnostics.verbose
359
375
  @diagnostics.quiet_baseline(passes: @completed_baseline_passes, elapsed: @baseline_elapsed,
@@ -367,11 +383,13 @@ module Saboteur
367
383
  end
368
384
 
369
385
  # This run's map is the whole-suite map only when nothing scoped it:
370
- # no --since, no subjects, and tests either unset or inferred over every
371
- # included file (never an explicit --test list) (review C1).
386
+ # no --since, no subjects, and every spec file loaded (--all or
387
+ # c.test_selection = :all). Inferred specs are a selection, never the
388
+ # whole suite: a spec with no matching source file is not in the map
389
+ # (#1286).
372
390
  def whole_suite_run?
373
391
  config["since"].nil? && config["subjects"].empty? &&
374
- (config["tests"].empty? || !@guessed_tests.nil?)
392
+ config["tests"].empty? && @guessed_tests.nil?
375
393
  end
376
394
 
377
395
  def coverage_store(integration, load_lines, load_methods = {})
@@ -18,10 +18,13 @@ module Saboteur
18
18
  # else the class of the error it died with (a crash, not a check).
19
19
  # executed_ids: the tests the child reports it ran; empty when it could
20
20
  # not say (a test called exit).
21
+ # value_differed: whether the mutated expression ever produced a value
22
+ # different from the original (SiteProbe); nil when the site is not
23
+ # probeable, never ran, or the run did not survive (#1290).
21
24
  Outcome = Data.define(:status, :runtime, :failed_ids, :message, :leftover_pids, :kill_cause,
22
- :executed_ids) do
25
+ :executed_ids, :value_differed) do
23
26
  def initialize(status:, runtime:, failed_ids: [], message: nil, leftover_pids: [], kill_cause: nil,
24
- executed_ids: [])
27
+ executed_ids: [], value_differed: nil)
25
28
  super
26
29
  end
27
30
  end
@@ -74,6 +77,9 @@ module Saboteur
74
77
  # sandbox: when given, a callable run first in every child (Landlock
75
78
  # makes the project tree read-only there, #152). It raises rather than
76
79
  # run half-restricted, which the child reports as an error.
80
+ # Survivor confirmation loads the spec files a scoped run left out (#1286).
81
+ attr_reader :integration
82
+
77
83
  def initialize(integration:, reevaluator:, log_dir:, hooks: Hooks.new, supervisor: Supervisor.new, seed: 1,
78
84
  sandbox: nil, canary: nil, crash_log: nil)
79
85
  @integration = integration
@@ -95,6 +101,7 @@ module Saboteur
95
101
  def run(subject:, text:, test_ids:, timeout:, mutation: nil, eval_prefix: nil)
96
102
  @hooks.run(:before_fork, mutation)
97
103
  @sequence = @sequence.to_i + 1
104
+ text = SiteProbe.text(subject, mutation, text) || text if mutation
98
105
  job = Job.new(subject:, text:, test_ids:, timeout:, mutation:, eval_prefix:,
99
106
  log_path: File.join(@log_dir, "child-#{Process.pid}-#{@sequence}.log"))
100
107
  log_file = open_log(job)
@@ -129,6 +136,7 @@ module Saboteur
129
136
  # Before the evaluation: a whole-file subject runs the file's own
130
137
  # top-level code there, and it must see the run's random numbers.
131
138
  Kernel.srand(@seed)
139
+ SiteProbe.reset
132
140
  evaluate_mutant(writer, job)
133
141
  stale = stale_captures(job)
134
142
  Protocol.emit(writer, "captures", site: stale.first, stale: stale.size) if stale.any?
@@ -167,7 +175,8 @@ module Saboteur
167
175
  Protocol.emit(writer, "error", message: "around_mutant hook did not run the tests")
168
176
  else
169
177
  called = flags[:replacement_called]
170
- Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called)
178
+ Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called,
179
+ value_differed: SiteProbe.differed)
171
180
  # A reload after the mutant already ran (an after-hook restore)
172
181
  # is no reason to doubt the verdict; only a never-invoked mutant
173
182
  # can have been hidden by the overwrite.
@@ -391,7 +400,8 @@ module Saboteur
391
400
  return mismatch(runtime, "executed #{executed.size} of #{test_ids.size} requested tests")
392
401
  end
393
402
 
394
- Outcome.new(status: :survived, runtime: runtime, executed_ids: executed)
403
+ Outcome.new(status: :survived, runtime: runtime, executed_ids: executed,
404
+ value_differed: result["value_differed"])
395
405
  end
396
406
 
397
407
  def killed_outcome(result, runtime, failed, executed, verbose_failure)
@@ -0,0 +1,161 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+
6
+ module Saboteur
7
+ module Execution
8
+ # Records, in the mutant's child, whether the mutated expression produced
9
+ # a different value from the original one (#1290). The mutated site is
10
+ # wrapped as (observe((mutated), [operands]) { original }). The mutated
11
+ # expression runs exactly once and first, as it does unwrapped, so the
12
+ # suite sees the same calls and the same verdict. The original runs
13
+ # only when every operand is a core immutable (Integer, Float, Rational,
14
+ # Symbol, nil, true, false, a frozen String), so only built-in operators
15
+ # execute; with any other operand the site is not probed and reports
16
+ # nil. Only side-effect-free site shapes are wrapped at all (literals,
17
+ # local and instance variable reads, arithmetic and comparison on
18
+ # those).
19
+ module SiteProbe
20
+ OPERATORS = %i[+ - * / % ** < <= > >= == != <=> & | ^ ! -@ +@ ~].freeze
21
+ LEAVES = [Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::SymbolNode, Prism::NilNode,
22
+ Prism::TrueNode, Prism::FalseNode, Prism::LocalVariableReadNode,
23
+ Prism::InstanceVariableReadNode].freeze
24
+
25
+ @hits = 0
26
+ @differed = false
27
+ @comparable = true
28
+
29
+ class << self
30
+ # The def text with the mutated site wrapped, or nil when the site
31
+ # cannot be probed safely. mutated_text is what the run would
32
+ # evaluate unwrapped; the wrap is only offered when it is exactly
33
+ # the source with the mutation's edits applied.
34
+ def text(subject, mutation, mutated_text)
35
+ return nil unless subject.respond_to?(:whole_file_only?) && mutation.respond_to?(:edits)
36
+ return nil if subject.whole_file_only? || mutation.edits.size != 1
37
+
38
+ source = subject.source
39
+ return nil unless subject.apply(mutation.edits) == mutated_text
40
+
41
+ edit = mutation.edits.first
42
+ range = (edit.start_byte - subject.start_byte)...(edit.end_byte - subject.start_byte)
43
+ wrap(source.b, range, edit.replacement.b)&.force_encoding(Encoding::UTF_8)
44
+ rescue StandardError
45
+ nil # a probe must never turn a mutant run into an error
46
+ end
47
+
48
+ def reset
49
+ @hits = 0
50
+ @differed = false
51
+ @comparable = true
52
+ end
53
+
54
+ # nil: the site never ran, or its values could not be compared.
55
+ def differed
56
+ return nil if @hits.zero? || !@comparable
57
+
58
+ @differed
59
+ end
60
+
61
+ def observe(mutated, operands, &)
62
+ compare(mutated, &) if operands.all? { |operand| immutable?(operand) }
63
+ mutated
64
+ end
65
+
66
+ private
67
+
68
+ def immutable?(value)
69
+ case value
70
+ when Integer, Float, Rational, Symbol, nil, true, false then true
71
+ when String then value.frozen?
72
+ else false
73
+ end
74
+ end
75
+
76
+ def compare(mutated)
77
+ original = yield
78
+ @hits += 1
79
+ @differed ||= original != mutated
80
+ rescue StandardError
81
+ @comparable = false
82
+ end
83
+
84
+ def wrap(source, range, replacement)
85
+ top = site(Prism.parse(source).value, range)
86
+ # A bare edited literal differs from itself by construction: no signal.
87
+ return nil if top.nil? || LEAVES.any? { |leaf| top.is_a?(leaf) }
88
+
89
+ from = top.location.start_offset
90
+ to = top.location.end_offset
91
+ original = source.byteslice(from...to)
92
+ mutated = original.byteslice(0, range.begin - from) + replacement + original.byteslice((range.end - from)..)
93
+ return nil if (original + mutated).include?("\n")
94
+
95
+ source.byteslice(0, from) + probe(top, original, mutated) + source.byteslice(to..)
96
+ end
97
+
98
+ def probe(top, original, mutated)
99
+ reads = []
100
+ collect_reads(top, reads)
101
+ operands = reads.uniq.join(", ")
102
+ "(::Saboteur::Execution::SiteProbe.observe((#{mutated}), [#{operands}]) { #{original} })".b
103
+ end
104
+
105
+ def collect_reads(node, reads)
106
+ reads << node.slice if node.is_a?(Prism::LocalVariableReadNode) || node.is_a?(Prism::InstanceVariableReadNode)
107
+ node.compact_child_nodes.each { |child| collect_reads(child, reads) }
108
+ end
109
+
110
+ # The outermost pure expression around the edit, climbing while the
111
+ # parent is pure too.
112
+ def site(root, range)
113
+ path = path_to(root, range).grep_v(Prism::ArgumentsNode)
114
+ index = path.rindex { |node| pure?(node) }
115
+ return nil unless index
116
+
117
+ index -= 1 while index.positive? && climbable?(path[index - 1])
118
+ path[index]
119
+ end
120
+
121
+ def climbable?(node)
122
+ (node.is_a?(Prism::CallNode) || node.is_a?(Prism::ParenthesesNode)) && pure?(node)
123
+ end
124
+
125
+ def path_to(node, range)
126
+ path = [node]
127
+ loop do
128
+ child = node.compact_child_nodes.find do |c|
129
+ c.location.start_offset <= range.begin && range.end <= c.location.end_offset
130
+ end
131
+ return path unless child
132
+
133
+ path << child
134
+ node = child
135
+ end
136
+ end
137
+
138
+ def pure?(node)
139
+ case node
140
+ when *LEAVES then true
141
+ when Prism::ParenthesesNode then pure_parentheses?(node)
142
+ when Prism::CallNode then pure_call?(node)
143
+ else false
144
+ end
145
+ end
146
+
147
+ def pure_parentheses?(node)
148
+ body = node.body
149
+ body.is_a?(Prism::StatementsNode) && body.body.size == 1 && pure?(body.body.first)
150
+ end
151
+
152
+ def pure_call?(node)
153
+ return false unless OPERATORS.include?(node.name) && node.block.nil? && node.receiver
154
+
155
+ pure?(node.receiver) && Array(node.arguments&.arguments).size <= 1 &&
156
+ Array(node.arguments&.arguments).all? { |arg| pure?(arg) }
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -205,7 +205,7 @@ module Saboteur
205
205
 
206
206
  # A subject fails a gate when at least one of its mutants survived.
207
207
  def failing_subjects(scored)
208
- scored.select { |result| result.status == :survived }.map(&:subject).uniq.size
208
+ scored.select(&:survived?).map(&:subject).uniq.size
209
209
  end
210
210
 
211
211
  def over?(count, limit, executed)
@@ -124,6 +124,36 @@ module Saboteur
124
124
  world.example_groups.map { |group| File.expand_path(group.metadata[:file_path]) }.uniq
125
125
  end
126
126
 
127
+ # Loads spec files the run's own selection left out, in the parent, so a
128
+ # forked confirmation run can execute their examples (#1286). Files
129
+ # already loaded are skipped; a file that defines no group is an error,
130
+ # since its examples could never run.
131
+ def load_spec_files(paths)
132
+ loaded = test_files
133
+ absolute = paths.map { |path| File.expand_path(path, config.root) }.uniq
134
+ (absolute - loaded).each do |path|
135
+ require_inside_root(path)
136
+ before = world.example_groups.size
137
+ from_directory(config.root) { load(path) }
138
+ raise RefusedSpecPath, "#{path} defines no example group" if world.example_groups.size == before
139
+ end
140
+ end
141
+
142
+ # The ids come from a cached coverage map, which is untrusted input
143
+ # (#427): only a spec file that really lives under the project root,
144
+ # symlinks resolved, may be loaded.
145
+ def require_inside_root(path)
146
+ raise RefusedSpecPath, "refusing to load #{path}: not a *_spec.rb file" unless path.end_with?("_spec.rb")
147
+
148
+ root = File.realpath(config.root)
149
+ real = File.realpath(path)
150
+ return if real.start_with?("#{root}#{File::SEPARATOR}") && real.end_with?("_spec.rb")
151
+
152
+ raise RefusedSpecPath, "refusing to load #{path}: it resolves outside the project root"
153
+ rescue SystemCallError
154
+ raise RefusedSpecPath, "refusing to load #{path}: it does not exist under the project root"
155
+ end
156
+
127
157
  # Runs the tests in the order given: groups by their earliest example,
128
158
  # examples by position.
129
159
  def run(ids)