saboteur 0.5.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -0
  3. data/README.md +202 -42
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/comparison.rb +1 -0
  6. data/lib/saboteur/baseline/evidence.rb +11 -1
  7. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  8. data/lib/saboteur/baseline/workflow.rb +2 -1
  9. data/lib/saboteur/baseline_health.rb +40 -6
  10. data/lib/saboteur/cli/exit_status.rb +49 -4
  11. data/lib/saboteur/cli/help_options.rb +16 -0
  12. data/lib/saboteur/cli/presets.rb +36 -0
  13. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  14. data/lib/saboteur/cli/run_reporting.rb +75 -6
  15. data/lib/saboteur/cli.rb +80 -48
  16. data/lib/saboteur/config.rb +88 -13
  17. data/lib/saboteur/crash_report.rb +154 -0
  18. data/lib/saboteur/custom_map.rb +3 -1
  19. data/lib/saboteur/doctor.rb +64 -2
  20. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  21. data/lib/saboteur/engine/boot.rb +12 -1
  22. data/lib/saboteur/engine/canary.rb +41 -10
  23. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  24. data/lib/saboteur/engine/candidates.rb +4 -1
  25. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  26. data/lib/saboteur/engine/coverage_store.rb +48 -6
  27. data/lib/saboteur/engine/diagnostics.rb +23 -2
  28. data/lib/saboteur/engine/diff_scope.rb +24 -5
  29. data/lib/saboteur/engine/distribution.rb +124 -8
  30. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  31. data/lib/saboteur/engine/result_cache.rb +2 -1
  32. data/lib/saboteur/engine/run_budget.rb +63 -0
  33. data/lib/saboteur/engine/run_fingerprint.rb +5 -1
  34. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  35. data/lib/saboteur/engine/scheduler.rb +114 -18
  36. data/lib/saboteur/engine/subject_selection.rb +84 -5
  37. data/lib/saboteur/engine/suite_files.rb +3 -1
  38. data/lib/saboteur/engine/test_guess.rb +183 -23
  39. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  40. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  41. data/lib/saboteur/engine/verdict_check.rb +54 -18
  42. data/lib/saboteur/engine.rb +175 -96
  43. data/lib/saboteur/execution/protocol.rb +2 -1
  44. data/lib/saboteur/execution/runner.rb +179 -17
  45. data/lib/saboteur/execution/sandbox.rb +6 -1
  46. data/lib/saboteur/execution/supervisor.rb +18 -3
  47. data/lib/saboteur/execution/worker_pool.rb +87 -7
  48. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  49. data/lib/saboteur/frameworks/rails.rb +8 -10
  50. data/lib/saboteur/help.rb +55 -10
  51. data/lib/saboteur/ignore_list.rb +11 -1
  52. data/lib/saboteur/install.rb +4 -3
  53. data/lib/saboteur/integration/base.rb +7 -2
  54. data/lib/saboteur/integration/rspec.rb +40 -4
  55. data/lib/saboteur/mutation_source/builtin.rb +22 -13
  56. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  57. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  58. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  59. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  60. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  61. data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
  62. data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
  63. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  64. data/lib/saboteur/preview.rb +12 -2
  65. data/lib/saboteur/reporter/agent_commands.rb +49 -16
  66. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  67. data/lib/saboteur/reporter/agent_summary.rb +1 -1
  68. data/lib/saboteur/reporter/history_policy.rb +35 -0
  69. data/lib/saboteur/reporter/history_record.rb +4 -5
  70. data/lib/saboteur/reporter/next_steps.rb +14 -4
  71. data/lib/saboteur/reporter/score_history.rb +19 -3
  72. data/lib/saboteur/reporter/stryker.rb +19 -8
  73. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  74. data/lib/saboteur/reporter/terminal.rb +43 -10
  75. data/lib/saboteur/result.rb +25 -7
  76. data/lib/saboteur/selection/coverage_map.rb +2 -1
  77. data/lib/saboteur/selection/git_diff.rb +15 -5
  78. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  79. data/lib/saboteur/subject.rb +11 -0
  80. data/lib/saboteur/text.rb +42 -0
  81. data/lib/saboteur/version.rb +1 -1
  82. data/lib/saboteur.rb +16 -1
  83. metadata +20 -2
@@ -1,51 +1,211 @@
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
- # is not found. The run scopes to the guesses only when every changed
10
- # file has one.
11
+ # is not found. Packwerk packs (a directory with a package.yml) map to the
12
+ # spec directory inside the same pack, and controllers also map to request
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.
11
17
  class TestGuess
12
- 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
13
48
 
14
49
  SOURCE_ROOTS = %w[lib app].freeze
15
50
 
16
- def initialize(config)
51
+ def initialize(config, diagnostics: nil)
52
+ @diagnostics = diagnostics
17
53
  @root = File.realpath(config.root)
18
54
  @config = config
19
55
  end
20
56
 
21
- # specs: existing spec files (relative paths); unmatched: changed
22
- # source files with no guessed spec; changed: how many source files
23
- # changed.
24
57
  def call
25
- changed = changed_sources
26
- guesses = changed.to_h { |file| [file, guess_for(file)] }
27
- Result.new(specs: guesses.values.compact.uniq.sort, unmatched: guesses.select { |_, spec| spec.nil? }.keys,
28
- 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)
29
102
  end
30
103
 
31
104
  private
32
105
 
33
- # Changed files that saboteur would mutate, relative to the root.
34
- 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
35
143
  included = @config.included_files.to_set
36
- changed = Selection::GitDiff.changed_lines(@config["since"], root: @root).keys
37
- changed.map { |file| Selection::CoverageMap.canonical(file) }
38
- .select { |file| included.include?(file) }
39
- .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
40
165
  end
41
166
 
42
167
  def guess_for(file)
43
- stem = file.delete_suffix(".rb")
168
+ candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
169
+ end
170
+
171
+ # Candidate specs for a source file, most specific first: the nearest
172
+ # package.yml pack's own spec directory, then the app-wide one.
173
+ def candidates_for(file)
174
+ pack = pack_of(file)
175
+ # pack is nil for files outside packs: "#{nil}/" is "/", which
176
+ # never prefixes a relative path, so delete_prefix is a no-op there.
177
+ base = file.delete_prefix("#{pack}/")
178
+ spec_root = pack ? "#{pack}/spec" : "spec"
179
+ stem = base.delete_suffix(".rb")
44
180
  rest = stem.split("/", 2)
45
- candidates = ["spec/#{stem}_spec.rb"]
46
- candidates.unshift("spec/#{rest.last}_spec.rb", "spec/lib/#{rest.last}_spec.rb") if
47
- rest.size == 2 && SOURCE_ROOTS.include?(rest.first)
48
- candidates.find { |path| File.file?(File.join(@root, path)) }
181
+ rooted = rest.size == 2 && SOURCE_ROOTS.include?(rest.first)
182
+ names = rooted ? [rest.last, "lib/#{rest.last}", stem] : [stem]
183
+ names = names.flat_map { |name| [name, *request_names(name)] }.uniq
184
+ names.map { |name| "#{spec_root}/#{name}_spec.rb" }
185
+ end
186
+
187
+ # Rails tests controllers as request specs: spec/requests/users_spec.rb
188
+ # or spec/requests/users_controller_spec.rb for controllers/users_controller.rb.
189
+ def request_names(name)
190
+ return [] unless name.start_with?("controllers/")
191
+
192
+ path = name.delete_prefix("controllers/")
193
+ ["requests/#{path}", "requests/#{path.delete_suffix('_controller')}"].uniq
194
+ end
195
+
196
+ # The nearest ancestor directory holding a package.yml (a packwerk
197
+ # pack), relative to the root. The root itself is not a pack here:
198
+ # Pathname#ascend stops above "." for these relative paths.
199
+ def pack_of(file)
200
+ Pathname(file).ascend do |path|
201
+ dir = path.to_s
202
+ return dir if pack_dirs.include?(dir)
203
+ end
204
+ nil
205
+ end
206
+
207
+ def pack_dirs
208
+ @pack_dirs ||= Dir.glob("**/package.yml", base: @root).to_set { |path| File.dirname(path) }
49
209
  end
50
210
  end
51
211
  end
@@ -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
@@ -22,11 +22,14 @@ module Saboteur
22
22
  @baseline_timeout = baseline_timeout
23
23
  end
24
24
 
25
+ # Why a result the budget never let the check run is not a verdict.
26
+ BUDGET_REASON = "the --max-duration budget stopped the verdict check before it could run"
27
+
25
28
  def call(results)
26
29
  fresh = results.reject(&:reused?)
27
- failing_runs = failing_sequences(fresh.select { |result| named_kill?(result) })
28
- failing = failing_claims(fresh.select { |result| unnamed_kill?(result) })
29
- results.map { |result| checked(result, failing_runs, failing) }
30
+ failing_runs, unchecked_runs = check_sequences(fresh.select { |result| named_kill?(result) })
31
+ failing, unchecked = check_claims(fresh.select { |result| unnamed_kill?(result) })
32
+ results.map { |result| checked(result, failing_runs, failing, unchecked_runs, unchecked) }
30
33
  end
31
34
 
32
35
  private
@@ -47,31 +50,64 @@ module Saboteur
47
50
  position ? result.covered_by.first(position + 1) : [result.killed_by.first]
48
51
  end
49
52
 
50
- # Runs each distinct kill sequence once. Returns those that fail, each
51
- # with why (the failure message, when there is one).
52
- def failing_sequences(kills)
53
- kills.map { |result| sequence(result) }.uniq.each_with_object({}) do |tests, failing|
53
+ # Runs each distinct kill sequence once. Returns [failing, unchecked]:
54
+ # failing maps a sequence to why it failed unchanged, unchecked maps
55
+ # the sequences the budget never let run to why - an unverified re-run
56
+ # is not a rejection (#296).
57
+ def check_sequences(kills)
58
+ failing = {}
59
+ unchecked = {}
60
+ kills.map { |result| sequence(result) }.uniq.each do |tests|
54
61
  outcome = @run_unchanged.call(tests, @baseline_timeout)
55
- failing[tests] = outcome.message unless outcome.status == :survived
62
+ if outcome.status == :unverified
63
+ unchecked[tests] = outcome.message
64
+ elsif outcome.status != :survived
65
+ failing[tests] = outcome.message
66
+ end
56
67
  end
68
+ [failing, unchecked]
57
69
  end
58
70
 
59
- # Kills with no failing test named. Returns those that fail unchanged.
60
- def failing_claims(claims)
61
- claims.reject do |result|
62
- @run_unchanged.call(result.covered_by, @baseline_timeout).status == :survived
71
+ # Kills with no failing test named, checked on all their tests.
72
+ # Returns [failing, unchecked]: failing maps a rejected result to why
73
+ # it failed unchanged, unchecked maps a result the budget never let
74
+ # run to why.
75
+ def check_claims(claims)
76
+ failing = {}
77
+ unchecked = {}
78
+ claims.each do |result|
79
+ outcome = @run_unchanged.call(result.covered_by, @baseline_timeout)
80
+ if outcome.status == :unverified
81
+ unchecked[result] = outcome.message
82
+ elsif outcome.status != :survived
83
+ failing[result] = outcome.message
84
+ end
63
85
  end
86
+ [failing, unchecked]
64
87
  end
65
88
 
66
- def checked(result, failing_runs, failing)
89
+ def checked(result, failing_runs, failing, unchecked_runs, unchecked)
67
90
  return result if result.reused?
91
+ return checked_named(result, failing_runs, unchecked_runs) if named_kill?(result)
92
+
93
+ if failing.key?(result)
94
+ why = failing[result]
95
+ result.as_error("tests also fail with the method unchanged#{": #{why}" if why}", rejected_kill: true)
96
+ elsif unchecked.key?(result)
97
+ result.as_unverified(unchecked[result] || BUDGET_REASON)
98
+ else
99
+ result
100
+ end
101
+ end
68
102
 
69
- if named_kill?(result) && failing_runs.key?(sequence(result))
70
- why = failing_runs[sequence(result)]
71
- result.as_error("killing test #{sequence(result).last} also fails with the method unchanged" \
103
+ def checked_named(result, failing_runs, unchecked_runs)
104
+ tests = sequence(result)
105
+ if failing_runs.key?(tests)
106
+ why = failing_runs[tests]
107
+ result.as_error("killing test #{tests.last} also fails with the method unchanged" \
72
108
  "#{": #{why}" if why}", rejected_kill: true)
73
- elsif failing.include?(result)
74
- result.as_error("tests also fail with the method unchanged", rejected_kill: true)
109
+ elsif unchecked_runs.key?(tests)
110
+ result.as_unverified(unchecked_runs[tests] || BUDGET_REASON)
75
111
  else
76
112
  result
77
113
  end