saboteur 0.6.0 → 0.7.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +166 -40
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +49 -4
  8. data/lib/saboteur/cli/help_options.rb +16 -0
  9. data/lib/saboteur/cli/presets.rb +36 -0
  10. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  11. data/lib/saboteur/cli/run_reporting.rb +74 -5
  12. data/lib/saboteur/cli.rb +67 -46
  13. data/lib/saboteur/config.rb +66 -11
  14. data/lib/saboteur/crash_report.rb +154 -0
  15. data/lib/saboteur/custom_map.rb +3 -1
  16. data/lib/saboteur/doctor.rb +40 -1
  17. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  18. data/lib/saboteur/engine/boot.rb +12 -1
  19. data/lib/saboteur/engine/canary.rb +41 -10
  20. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  21. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  22. data/lib/saboteur/engine/coverage_store.rb +48 -6
  23. data/lib/saboteur/engine/diagnostics.rb +23 -2
  24. data/lib/saboteur/engine/diff_scope.rb +24 -5
  25. data/lib/saboteur/engine/distribution.rb +124 -8
  26. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  27. data/lib/saboteur/engine/result_cache.rb +2 -1
  28. data/lib/saboteur/engine/run_budget.rb +63 -0
  29. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  30. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  31. data/lib/saboteur/engine/scheduler.rb +114 -18
  32. data/lib/saboteur/engine/subject_selection.rb +84 -5
  33. data/lib/saboteur/engine/suite_files.rb +3 -1
  34. data/lib/saboteur/engine/test_guess.rb +145 -22
  35. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  36. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  37. data/lib/saboteur/engine/verdict_check.rb +54 -18
  38. data/lib/saboteur/engine.rb +166 -95
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/runner.rb +179 -17
  41. data/lib/saboteur/execution/sandbox.rb +6 -1
  42. data/lib/saboteur/execution/supervisor.rb +18 -3
  43. data/lib/saboteur/execution/worker_pool.rb +87 -7
  44. data/lib/saboteur/help.rb +55 -11
  45. data/lib/saboteur/ignore_list.rb +11 -1
  46. data/lib/saboteur/install.rb +4 -3
  47. data/lib/saboteur/integration/base.rb +7 -2
  48. data/lib/saboteur/integration/rspec.rb +40 -4
  49. data/lib/saboteur/mutation_source/builtin.rb +20 -11
  50. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  51. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  52. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  53. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  54. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  55. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  56. data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
  57. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  58. data/lib/saboteur/preview.rb +2 -1
  59. data/lib/saboteur/reporter/agent_commands.rb +38 -13
  60. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  61. data/lib/saboteur/reporter/next_steps.rb +14 -4
  62. data/lib/saboteur/reporter/score_history.rb +5 -1
  63. data/lib/saboteur/reporter/stryker.rb +13 -4
  64. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  65. data/lib/saboteur/reporter/terminal.rb +34 -10
  66. data/lib/saboteur/result.rb +22 -7
  67. data/lib/saboteur/selection/coverage_map.rb +2 -1
  68. data/lib/saboteur/selection/git_diff.rb +15 -5
  69. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  70. data/lib/saboteur/subject.rb +11 -0
  71. data/lib/saboteur/text.rb +42 -0
  72. data/lib/saboteur/version.rb +1 -1
  73. data/lib/saboteur.rb +16 -1
  74. metadata +17 -2
@@ -27,8 +27,9 @@ module Saboteur
27
27
  tests.sort_by.with_index { |id, index| [-killers.fetch(id, 0), index] }
28
28
  end
29
29
 
30
- def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil)
30
+ def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil, deadline: nil)
31
31
  @config = config
32
+ @deadline = deadline
32
33
  @runner = runner
33
34
  @map = map
34
35
  @reevaluator = reevaluator
@@ -73,23 +74,53 @@ module Saboteur
73
74
 
74
75
  # Verdicts that did not hold up are warned about once per subject;
75
76
  # only checked verdicts are stored for --incremental.
77
+ # A spent budget admits no check: forking the unchanged run with no
78
+ # time left would turn genuine kills into fabricated errors and cache
79
+ # them. Fresh kills stand unverified instead - outside the score,
80
+ # never cached - and the distribution marks the subject so the run
81
+ # reports budget-partial.
76
82
  def checked(plan, results)
83
+ return results.map { |result| unverifiable(result) } if budget_spent?
84
+
77
85
  subject = plan.subject
78
- run_unchanged = lambda do |tests, timeout|
79
- @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: timeout,
80
- eval_prefix: eval_prefix(plan))
81
- .tap { |outcome| note_leftovers(subject, "verdict check", outcome) }
82
- end
83
- check = VerdictCheck.new(run_unchanged: run_unchanged, baseline_timeout: @config["baseline_timeout"])
86
+ check = VerdictCheck.new(run_unchanged: unchanged_runner(subject, plan),
87
+ baseline_timeout: @config["baseline_timeout"])
84
88
  final = check.call(results)
85
- final.reject(&:reused?).each { |result| @cache&.record(result) }
86
- overturned = final.zip(results).count { |after, before| !after.equal?(before) }
89
+ # Unverified verdicts are never cached: a budget-cut check proves
90
+ # nothing, and a stored copy would read as confirmed next run.
91
+ final.reject { |result| result.reused? || result.status == :unverified }.each { |r| @cache&.record(r) }
92
+ overturned = final.zip(results).count { |after, before| after.status == :error && !after.equal?(before) }
87
93
  if overturned.positive?
88
94
  @diagnostics.warn("#{subject.id}: #{overturned} verdict(s) failed with the method unchanged")
89
95
  end
90
96
  final
91
97
  end
92
98
 
99
+ # Admission is per sequence, not just at checked() entry: a check
100
+ # admitted with time left can hit its capped timeout mid-check, and a
101
+ # later sequence can arrive after the budget is spent. Neither may
102
+ # fork with no time left, and neither outcome is a rejected kill -
103
+ # the check never got its answer (#296).
104
+ def unchanged_runner(subject, plan)
105
+ lambda do |tests, timeout|
106
+ if budget_spent?
107
+ Execution::Runner::Outcome.new(status: :unverified, runtime: 0.0,
108
+ message: "the --max-duration budget is spent")
109
+ else
110
+ capped = budget_capped(timeout)
111
+ outcome = @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: capped,
112
+ eval_prefix: eval_prefix(plan))
113
+ note_leftovers(subject, "verdict check", outcome)
114
+ if outcome.status == :timeout && capped < timeout
115
+ Execution::Runner::Outcome.new(status: :unverified, runtime: outcome.runtime,
116
+ message: "the --max-duration budget cut the verdict check short")
117
+ else
118
+ outcome
119
+ end
120
+ end
121
+ end
122
+ end
123
+
93
124
  # When every verdict can be reused, nothing forks: not even the neutral
94
125
  # check. Returns the results, or nil at the first one that must run.
95
126
  def all_reused(plan, candidates)
@@ -100,10 +131,16 @@ module Saboteur
100
131
  end
101
132
  end
102
133
 
134
+ # The budget deadline is checked between mutants, so a spent
135
+ # --max-duration stops inside a long subject too; completed results
136
+ # are kept and the distribution marks what follows as unreached.
103
137
  def run_candidates(candidates, &run_one)
104
138
  coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
105
- results = coarse.map { |mutation, text| run_one.call(mutation, text) }
106
- results + fine.map { |mutation, text| run_one.call(mutation, text) }
139
+ (coarse + fine).each_with_object([]) do |(mutation, text), results|
140
+ break results if @deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
141
+
142
+ results << run_one.call(mutation, text)
143
+ end
107
144
  end
108
145
 
109
146
  # A stored verdict that still holds, as a result; nil when it must run.
@@ -206,19 +243,78 @@ module Saboteur
206
243
  # baseline_timeout. If the deadline from that real runtime is longer,
207
244
  # the mutant runs again with it, and later mutants of the subject get
208
245
  # the measured slowdown. A timeout that remains is the mutant's own.
246
+ # A --max-duration deadline caps every admission the scheduler makes:
247
+ # verdict checks and timeout calibrations get only the time left.
248
+ def budget_capped(timeout)
249
+ return timeout unless @deadline
250
+
251
+ remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
252
+ remaining.clamp(0.0, timeout)
253
+ end
254
+
255
+ def budget_spent?
256
+ @deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
257
+ end
258
+
259
+ # A fresh kill whose check the budget skipped proves nothing yet: it
260
+ # becomes unverified, outside the score, and is never cached.
261
+ def unverifiable(result)
262
+ return result if result.reused? || result.status != :killed
263
+
264
+ result.as_unverified("verdict check skipped: the --max-duration budget is spent")
265
+ end
266
+
267
+ # A timeout the budget keeps the calibration from judging proves
268
+ # nothing either: no zero-time forks, and a retry cut short does not
269
+ # count as detected.
209
270
  def calibrate(plan, mutation, text, tests, timeout)
210
- unchanged = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
211
- timeout: @config["baseline_timeout"], eval_prefix: eval_prefix(plan))
212
- note_leftovers(plan.subject, "timeout check", unchanged)
271
+ if budget_spent?
272
+ return unverified_outcome(timeout, "timeout calibration skipped: the --max-duration budget is spent")
273
+ end
274
+
275
+ unchanged, shortened = unchanged_timing(plan, tests)
276
+ if unchanged.status == :timeout && shortened
277
+ return unverified_outcome(unchanged.runtime, "the --max-duration budget cut the timeout calibration short")
278
+ end
213
279
  return unchanged_failed(unchanged) unless unchanged.status == :survived
214
280
 
281
+ retry_longer(plan, mutation, text, tests, [timeout, unchanged.runtime])
282
+ end
283
+
284
+ # The unchanged method under the budget-capped baseline timeout, and
285
+ # whether the cap shortened it.
286
+ def unchanged_timing(plan, tests)
287
+ capped = budget_capped(@config["baseline_timeout"])
288
+ outcome = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
289
+ timeout: capped, eval_prefix: eval_prefix(plan))
290
+ note_leftovers(plan.subject, "timeout check", outcome)
291
+ [outcome, capped < @config["baseline_timeout"]]
292
+ end
293
+
294
+ def retry_longer(plan, mutation, text, tests, limit)
295
+ timeout, unchanged_runtime = limit
215
296
  recorded = @map.recorded_runtime(tests)
216
- plan.slowdown = [plan.slowdown, unchanged.runtime / recorded].max if recorded&.positive?
217
- longer = deadline(unchanged.runtime)
297
+ plan.slowdown = [plan.slowdown, unchanged_runtime / recorded].max if recorded&.positive?
298
+ longer = deadline(unchanged_runtime)
218
299
  return Execution::Runner::Outcome.new(status: :timeout, runtime: timeout) if longer <= timeout
300
+ # The unchanged timing may have used the final allowance: no
301
+ # zero-time retry fork (#296).
302
+ if budget_spent?
303
+ return unverified_outcome(timeout, "the --max-duration budget is spent before the mutant retry")
304
+ end
305
+
306
+ retry_timeout = budget_capped(longer)
307
+ retried = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: retry_timeout,
308
+ mutation: mutation, eval_prefix: eval_prefix(plan))
309
+ return retried unless retried.status == :timeout && retry_timeout < longer
310
+
311
+ unverified_outcome(retried.runtime, "the --max-duration budget cut the mutant retry short")
312
+ end
219
313
 
220
- @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: longer, mutation: mutation,
221
- eval_prefix: eval_prefix(plan))
314
+ # A budget-cut calibration proves nothing about the mutant: outside
315
+ # the score, and never cached.
316
+ def unverified_outcome(runtime, message)
317
+ Execution::Runner::Outcome.new(status: :unverified, runtime: runtime, message: message)
222
318
  end
223
319
 
224
320
  def unchanged_failed(unchanged)
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "../text"
5
+
4
6
  module Saboteur
5
7
  class Engine
6
8
  # The subjects to mutate: every def in the included files, narrowed by
@@ -13,11 +15,12 @@ module Saboteur
13
15
  @config = config
14
16
  @diagnostics = diagnostics
15
17
  @root = File.realpath(config.root)
16
- @changed_lines = nil
17
18
  end
18
19
 
19
20
  def select(files)
21
+ @files = files
20
22
  subjects = find(files)
23
+ @discovered = subjects
21
24
  subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
22
25
  subjects = by_change(subjects, @config["since"]) if @config["since"]
23
26
  subjects
@@ -28,6 +31,22 @@ module Saboteur
28
31
  id == pattern || %w[# . ::].any? { |separator| id.start_with?("#{pattern}#{separator}") }
29
32
  end
30
33
 
34
+ # The files (absolute paths) holding a subject matching any pattern,
35
+ # returned relative to root. --subject inference guesses specs for
36
+ # exactly these, so one unrelated spec-less file cannot block a
37
+ # one-method run.
38
+ def self.files_matching(root, files, patterns)
39
+ finder = SubjectFinder.new
40
+ prefix = "#{root}/"
41
+ files.filter_map do |file|
42
+ parsed = finder.call(file)
43
+ matching = parsed.subjects.any? do |subject|
44
+ patterns.any? { |pattern| match?(subject.id, pattern) }
45
+ end
46
+ file.delete_prefix(prefix) if matching
47
+ end.sort
48
+ end
49
+
31
50
  private
32
51
 
33
52
  def find(files)
@@ -54,14 +73,74 @@ module Saboteur
54
73
  end
55
74
 
56
75
  def by_change(subjects, ref)
57
- @changed_lines = Selection::GitDiff.changed_lines(ref, root: @root)
76
+ @changed_lines = Selection::GitDiff.changed_lines(ref, root: @root, include: @files.to_set,
77
+ diagnostics: @diagnostics)
58
78
  .transform_keys { |file| Selection::CoverageMap.canonical(file) }
59
- changed = subjects.select do |subject|
60
- @changed_lines[subject.file]&.any? { |line| subject.contains_line?(line) }
61
- end
79
+ changed = changed_subjects(subjects)
80
+ warn_changed_files_without_subjects
81
+ warn_excluded_changed_files
62
82
  drop_unchanged_syntax(changed, ref)
63
83
  end
64
84
 
85
+ # subject.file carries the locale tag under a non-UTF-8 locale
86
+ # while the diff keys are UTF-8; retag losslessly before lookup so
87
+ # a non-ASCII path matches by its exact bytes (#231).
88
+ def changed_subjects(subjects)
89
+ subjects.select do |subject|
90
+ key = Selection::CoverageMap.canonical(Text.utf8_identity(subject.file))
91
+ lines = @changed_lines[key]
92
+ lines && subject.overlaps_lines?(lines)
93
+ end
94
+ end
95
+
96
+ # A changed Ruby file in scope whose changed lines cover no subject
97
+ # silently contributes nothing to the run (#232): warn, or a typo, an
98
+ # unparseable file or a selection bug passes as a clean selection.
99
+ # Comment-only changes stay quiet: their file matched subjects before
100
+ # drop_unchanged_syntax pruned them, and that prune is reported. A
101
+ # diff touching no included Ruby file is a legitimate empty scope.
102
+ def warn_changed_files_without_subjects
103
+ canonical = ->(file) { Selection::CoverageMap.canonical(Text.utf8_identity(file)) }
104
+ included = @files.map(&canonical)
105
+ matched = changed_subjects(@discovered).map { |subject| canonical.call(subject.file) }
106
+ unmatched = (@changed_lines.keys.select { |file| file.end_with?(".rb") } & included) - matched
107
+ return if unmatched.empty?
108
+
109
+ names = unmatched.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
110
+ more = unmatched.size > 5 ? ", ..." : ""
111
+ @diagnostics.warn("--since: #{unmatched.size} changed Ruby file(s) have no subjects covering " \
112
+ "the changed lines, so nothing is mutated from them: #{names}#{more}. The " \
113
+ "file may not parse, or every change may sit outside a method.")
114
+ end
115
+
116
+ # A changed Ruby file that an include pattern covers but c.exclude
117
+ # removes yields zero candidates with no other sign - this repo's own
118
+ # config excludes lib/saboteur/execution/**, so harness changes once
119
+ # dogfooded as a clean empty scope (#282). Names at most five.
120
+ def warn_excluded_changed_files
121
+ untracked = Selection::UntrackedChanges.paths(@root)
122
+ hits = excluded_territory & (@changed_lines.keys + untracked)
123
+ return if hits.empty?
124
+
125
+ names = hits.sort.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
126
+ more = hits.size > 5 ? ", ..." : ""
127
+ @diagnostics.warn("--since: #{hits.size} changed Ruby file(s) are covered by c.include but removed by " \
128
+ "c.exclude, so nothing is mutated from them: #{names}#{more}. Narrow or remove the " \
129
+ "c.exclude entry, or pass the file as a positional source operand (configured " \
130
+ "excludes are then ignored).")
131
+ end
132
+
133
+ # Ruby files an include pattern and an exclude pattern both match,
134
+ # canonicalized the way the diff map keys are.
135
+ def excluded_territory
136
+ canonical = lambda do |file|
137
+ Selection::CoverageMap.canonical(File.expand_path(Text.utf8_identity(file), @config.root))
138
+ end
139
+ territory = @config["include"].flat_map { |pattern| Dir.glob(pattern, base: @config.root) }
140
+ excluded = @config["exclude"].flat_map { |pattern| Dir.glob(pattern, base: @config.root) }
141
+ (territory & excluded).select { |file| file.end_with?(".rb") }.map(&canonical)
142
+ end
143
+
65
144
  # A method whose only changes are comments, blank lines or spacing has
66
145
  # nothing new to test.
67
146
  def drop_unchanged_syntax(subjects, ref)
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "digest"
5
5
  require "shellwords"
6
+ require_relative "../text"
6
7
  require_relative "data_inputs"
7
8
 
8
9
  module Saboteur
@@ -56,7 +57,8 @@ module Saboteur
56
57
  # `--require name` resolves through RSpec's load path: spec/, lib/ and
57
58
  # any -I directory.
58
59
  def required_by_options
59
- tokens = option_files.flat_map { |file| Shellwords.split(File.read(file)) } + @config["rspec_args"]
60
+ tokens = option_files.flat_map { |file| Shellwords.split(Text.utf8_identity(File.read(file))) } +
61
+ @config["rspec_args"]
60
62
  load_path = LOAD_PATH + option_values(tokens, "-I", "--include")
61
63
  option_values(tokens, *REQUIRE_FLAGS).filter_map { |name| resolve(name, load_path) }
62
64
  end
@@ -1,43 +1,167 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "pathname"
5
+
4
6
  module Saboteur
5
7
  class Engine
6
- # --guess-tests: the specs that sit next to the changed source files by
8
+ # The specs that sit next to the mutated source files by
7
9
  # naming convention (lib/a/b.rb -> spec/a/b_spec.rb). A guess, not a
8
10
  # coverage fact: a spec that only reaches the file through another one
9
11
  # is not found. Packwerk packs (a directory with a package.yml) map to the
10
12
  # spec directory inside the same pack, and controllers also map to request
11
- # specs. The run scopes to the guesses only when every changed file has one.
13
+ # specs. With --since, only files whose changed lines carry a mutation
14
+ # subject need a guessed spec; without --since, every included file with
15
+ # a mutation subject needs one. A file that needs a spec and has none
16
+ # stops the run before boot.
12
17
  class TestGuess
13
- Result = Data.define(:specs, :unmatched, :changed)
18
+ # hints: unmatched file -> its conventional spec path (which does
19
+ # not exist; that is why the file is unmatched), for the stop-before-boot
20
+ # error. The result also builds its own messages, so a stubbed guess in
21
+ # the engine's specs formats exactly like a real one.
22
+ Result = Data.define(:specs, :unmatched, :subjectless, :changed, :hints) do
23
+ # The stop-before-boot usage error: names each expected spec and the
24
+ # ways out as an append-only instruction - never a reconstructed
25
+ # command, which could carry a hostile ref or a wrong source scope.
26
+ def unmatched_message
27
+ pairs = unmatched.map { |file| "#{file} (expected #{hints.fetch(file)})" }
28
+ "no spec found for #{pairs.join(', ')}, so nothing ran. Re-run the same command with --test " \
29
+ "followed by your existing spec paths, or append --all to use the whole suite without " \
30
+ "changing the source scope."
31
+ end
32
+
33
+ # Why the subjectless files needed no spec.
34
+ def subjectless_message(since:)
35
+ detail = since ? "no changed mutation subjects" : "no mutation subjects"
36
+ "inferred specs: no spec is required for #{subjectless.join(', ')}: they have #{detail}"
37
+ end
38
+
39
+ # The warning for a guess-scoped run: the guessed specs now stand as
40
+ # explicit tests, with the cross-file coverage caveat that brings.
41
+ def scope_message(since:)
42
+ what = since ? "a changed file" : "a source file"
43
+ "inferred specs: running only #{specs.join(', ')}. A spec that reaches " \
44
+ "#{what} only through another file does not run, so a mutant only it would kill can " \
45
+ "survive, and a line no inferred spec executes is reported as NoCoverage"
46
+ end
47
+ end
14
48
 
15
49
  SOURCE_ROOTS = %w[lib app].freeze
16
50
 
17
- def initialize(config)
51
+ def initialize(config, diagnostics: nil)
52
+ @diagnostics = diagnostics
18
53
  @root = File.realpath(config.root)
19
54
  @config = config
20
55
  end
21
56
 
22
- # specs: existing spec files (relative paths); unmatched: changed
23
- # source files with no guessed spec; changed: how many source files
24
- # changed.
25
57
  def call
26
- changed = changed_sources
27
- guesses = changed.to_h { |file| [file, guess_for(file)] }
28
- Result.new(specs: guesses.values.compact.uniq.sort, unmatched: guesses.select { |_, spec| spec.nil? }.keys,
29
- changed: changed.size)
58
+ @config["since"] ? call_diff : call_all
59
+ end
60
+
61
+ # Subject-scoped inference (--subject runs): only the named subjects'
62
+ # files need a guessed spec; every one of them has a subject by
63
+ # construction, so there is no subjectless bucket. With --since the
64
+ # diff narrows the scope further: a named subject whose file is
65
+ # unchanged, whose changed lines sit in other methods, or whose change
66
+ # is comment or formatting only is out of scope and needs no spec.
67
+ def call_for(files, patterns = [])
68
+ files = effective_subject_files(files, patterns) if @config["since"]
69
+ guesses = files.to_h { |file| [file, guess_for(file)] }
70
+ result(guesses, [], files.size)
71
+ end
72
+
73
+ # specs: existing spec files (relative paths) guessed for the files in
74
+ # scope; unmatched: such files with no guessed spec; subjectless: files
75
+ # with no mutation subject the run checks, which neither need nor get a
76
+ # spec; changed: how many source files are in scope; hints: each
77
+ # unmatched file's conventional spec path.
78
+ def call_diff
79
+ lines_by_file = changed_lines_by_file
80
+ subjectless = subjectless_files(lines_by_file)
81
+ # Only files with a changed mutation subject get a guessed spec: a
82
+ # subjectless file mutates nothing, so its spec would only narrow the
83
+ # run for no reason.
84
+ guesses = lines_by_file.except(*subjectless).to_h { |file| [file, guess_for(file)] }
85
+ result(guesses, subjectless, lines_by_file.size)
86
+ end
87
+
88
+ # Without --since every included file is in scope: a file needs its
89
+ # guessed spec unless it holds no mutation subject at all.
90
+ def call_all
91
+ finder = SubjectFinder.new
92
+ guesses = {}
93
+ subjectless = []
94
+ included_files.each do |file|
95
+ if finder.call(File.join(@root, file)).subjects.empty?
96
+ subjectless << file
97
+ else
98
+ guesses[file] = guess_for(file)
99
+ end
100
+ end
101
+ result(guesses, subjectless.sort, guesses.size + subjectless.size)
30
102
  end
31
103
 
32
104
  private
33
105
 
34
- # Changed files that saboteur would mutate, relative to the root.
35
- def changed_sources
106
+ # The named subjects' files inside the diff scope: the subject must
107
+ # sit on a changed line and survive the comment/formatting prune,
108
+ # mirroring the run's subject selection.
109
+ def effective_subject_files(files, patterns)
110
+ lines_by_file = changed_lines_by_file
111
+ comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(@config["since"], root: @root), root: @root)
112
+ finder = SubjectFinder.new
113
+ files.select do |file|
114
+ lines = lines_by_file[file]
115
+ next false unless lines
116
+
117
+ finder.call(File.join(@root, file)).subjects.any? do |subject|
118
+ patterns.any? { |pattern| SubjectSelection.match?(subject.id, pattern) } &&
119
+ subject.overlaps_lines?(lines) &&
120
+ !comparison.unchanged?(subject)
121
+ end
122
+ end
123
+ end
124
+
125
+ def result(guesses, subjectless, changed)
126
+ unmatched = guesses.select { |_, spec| spec.nil? }.keys
127
+ Result.new(specs: guesses.values.compact.uniq.sort,
128
+ unmatched: unmatched,
129
+ subjectless: subjectless,
130
+ changed: changed,
131
+ hints: unmatched.to_h { |file| [file, candidates_for(file).first] })
132
+ end
133
+
134
+ # Included files relative to the root, sorted for stable output.
135
+ def included_files
136
+ prefix = "#{@root}/"
137
+ @config.included_files.filter_map { |file| file.delete_prefix(prefix) if file.start_with?(prefix) }.sort
138
+ end
139
+
140
+ # Changed files that saboteur would mutate, relative to the root, with
141
+ # their changed line numbers.
142
+ def changed_lines_by_file
36
143
  included = @config.included_files.to_set
37
- changed = Selection::GitDiff.changed_lines(@config["since"], root: @root).keys
38
- changed.map { |file| Selection::CoverageMap.canonical(file) }
39
- .select { |file| included.include?(file) }
40
- .map { |file| file.delete_prefix("#{@root}/") }.sort
144
+ Selection::GitDiff.changed_lines(@config["since"], root: @root, include: included,
145
+ diagnostics: @diagnostics)
146
+ .each_with_object({}) do |(file, lines), map|
147
+ canonical = Selection::CoverageMap.canonical(file)
148
+ map[canonical.delete_prefix("#{@root}/")] = lines if included.include?(canonical)
149
+ end.sort.to_h
150
+ end
151
+
152
+ # Changed files whose changed lines carry no mutation subject: the run
153
+ # mutates nothing in them, so they cannot require a spec. Mirrors the
154
+ # run's subject selection, including its comment/formatting-only skip:
155
+ # a deletion whose remaining context lines are blank or comments, or a
156
+ # comment-only change inside a method, leaves the file subjectless.
157
+ def subjectless_files(lines_by_file)
158
+ comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(@config["since"], root: @root), root: @root)
159
+ finder = SubjectFinder.new
160
+ lines_by_file.reject do |file, lines|
161
+ subjects = finder.call(File.join(@root, file)).subjects
162
+ subjects.select { |subject| subject.overlaps_lines?(lines) }
163
+ .any? { |subject| !comparison.unchanged?(subject) }
164
+ end.keys
41
165
  end
42
166
 
43
167
  def guess_for(file)
@@ -71,14 +195,13 @@ module Saboteur
71
195
 
72
196
  # The nearest ancestor directory holding a package.yml (a packwerk
73
197
  # pack), relative to the root. The root itself is not a pack here:
74
- # its specs follow the plain mapping.
198
+ # Pathname#ascend stops above "." for these relative paths.
75
199
  def pack_of(file)
76
- dir = File.dirname(file)
77
- until dir == "."
200
+ Pathname(file).ascend do |path|
201
+ dir = path.to_s
78
202
  return dir if pack_dirs.include?(dir)
79
-
80
- dir = File.dirname(dir)
81
203
  end
204
+ nil
82
205
  end
83
206
 
84
207
  def pack_dirs
@@ -0,0 +1,53 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Threads do not survive fork, so a run names the ones alive when the
7
+ # workers fork: by the name the owner gave the thread, else the first
8
+ # frame of its backtrace, so the user can tell whether they matter (a
9
+ # connection pool reaper, Sidekiq, an SDK's background flusher).
10
+ # Known-harmless threads say so inline; an unknown thread gets the
11
+ # generic caveat (#263).
12
+ module ThreadWarnings
13
+ # Well-known fork-time threads, with the condition each assurance
14
+ # hangs on. Evidence: rails-mode children reconnect the database
15
+ # (lib/saboteur/frameworks/rails.rb, after_fork); whether the debug
16
+ # gem's console also attaches to forked children depends on its
17
+ # fork_mode, whose default is :both, i.e. attach (RUBY_DEBUG_FORK_MODE;
18
+ # ruby/debug's config.rb and SESSION.activate on_fork: true in
19
+ # session.rb).
20
+ KNOWN_FORK_THREADS = {
21
+ "AR Pool Reaper" => "ActiveRecord's connection-pool reaper: expected harmless when children " \
22
+ "reconnect the database after fork (rails mode does) - it only maintains " \
23
+ "the parent's pool",
24
+ "DEBUGGER__::SESSION@server" => "the debug gem's remote-console server: what it does in " \
25
+ "children depends on RUBY_DEBUG_FORK_MODE - with parent no " \
26
+ "debugger runs in children; with child or both (the gem's " \
27
+ "default) the debugger activates in children and can pause or " \
28
+ "interfere with supervised mutation runs. For an unattended " \
29
+ "run, do not start the remote debugger at all: drop " \
30
+ "RUBY_DEBUG_OPEN or require \"debug\" without :open"
31
+ }.freeze
32
+
33
+ def self.warn(diagnostics)
34
+ others = Thread.list - [Thread.current]
35
+ return if others.empty?
36
+
37
+ unknown = false
38
+ list = others.map do |thread|
39
+ name = thread.name || thread.backtrace&.first || "unknown"
40
+ explanation = KNOWN_FORK_THREADS[name]
41
+ unknown = true unless explanation
42
+ explanation ? "#{name} (#{explanation})" : name
43
+ end.join(", ")
44
+ message = "#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}"
45
+ if unknown
46
+ message += "; a thread that does not run in a child cannot do background work a mutant's test may " \
47
+ "rely on (a pool reaper, a flusher), so results can differ when one is left behind"
48
+ end
49
+ diagnostics.warn(message)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # A whole-suite run (--all, c.test_selection = :all, or a --since diff
7
+ # with nothing to infer from) runs the loaded suite for the baseline
8
+ # checks; a cold coverage cache adds one more pass. Say so - with the
9
+ # example count and the real pass count - before those hours pass; past
10
+ # a size threshold, add the way to scope it (#260). The check count
11
+ # comes from c.baseline_passes (#55).
12
+ module UnscopedSuiteWarning
13
+ # A suite this size pays hours for an unscoped run; below it the cost
14
+ # summary alone is enough, without the --test lecture (#260).
15
+ COSTLY_SUITE_EXAMPLES = 1_000
16
+ REUSED_MAP_NOTE = "; the cached coverage map is reused"
17
+
18
+ def self.call(config:, diagnostics:, integration:, full_passes:)
19
+ return unless config["tests"].empty?
20
+ return unless config["since"] || config["all"] || config["test_selection"] == "all"
21
+
22
+ examples = integration.all_tests.size
23
+ coverage = full_passes == config["baseline_passes"] ? REUSED_MAP_NOTE : " + 1 coverage map build"
24
+ message = "running the loaded suite unscoped: #{examples} examples in #{integration.test_files.size} " \
25
+ "spec files; #{full_passes} full passes (#{checks_label(config)}#{coverage})."
26
+ if examples >= COSTLY_SUITE_EXAMPLES
27
+ message += " Pass --test to run only the specs you name: faster, but a mutant of a changed method is then " \
28
+ "killed only by those specs, so specs elsewhere that call this code are not run. " \
29
+ "The default inferred selection runs the specs named after the mutated files, " \
30
+ "a guess that can miss specs elsewhere."
31
+ end
32
+ diagnostics.warn(message)
33
+ diagnostics.inform(message)
34
+ end
35
+
36
+ def self.checks_label(config)
37
+ "#{config['baseline_passes']} baseline check#{'s' unless config['baseline_passes'] == 1}"
38
+ end
39
+ end
40
+ end
41
+ end