saboteur 0.11.1 → 0.11.2

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +1 -1
  4. data/lib/saboteur/cli/exit_status.rb +6 -2
  5. data/lib/saboteur/cli/explain_command.rb +51 -0
  6. data/lib/saboteur/cli/flaky_exit.rb +23 -0
  7. data/lib/saboteur/cli/run_command.rb +13 -0
  8. data/lib/saboteur/cli/run_output.rb +10 -0
  9. data/lib/saboteur/cli/run_reporting.rb +13 -2
  10. data/lib/saboteur/cli/survivors_command.rb +1 -1
  11. data/lib/saboteur/cli.rb +3 -0
  12. data/lib/saboteur/config.rb +7 -2
  13. data/lib/saboteur/doctor.rb +8 -1
  14. data/lib/saboteur/engine/coverage_store.rb +54 -3
  15. data/lib/saboteur/engine/distribution.rb +6 -2
  16. data/lib/saboteur/engine/pipelines.rb +14 -2
  17. data/lib/saboteur/engine/scheduler.rb +1 -1
  18. data/lib/saboteur/engine/selection_announcement.rb +5 -4
  19. data/lib/saboteur/engine/subject_selection.rb +10 -0
  20. data/lib/saboteur/engine.rb +35 -5
  21. data/lib/saboteur/help.rb +11 -0
  22. data/lib/saboteur/ignore_list.rb +22 -2
  23. data/lib/saboteur/mutation_source/builtin.rb +1 -0
  24. data/lib/saboteur/mutation_source/operator_explain.rb +181 -0
  25. data/lib/saboteur/reporter/agent_commands.rb +23 -6
  26. data/lib/saboteur/reporter/agent_incomplete.rb +9 -2
  27. data/lib/saboteur/reporter/agent_summary.rb +2 -1
  28. data/lib/saboteur/reporter/gap_kinds.rb +80 -0
  29. data/lib/saboteur/reporter/html.rb +19 -4
  30. data/lib/saboteur/reporter/review_comments.rb +6 -5
  31. data/lib/saboteur/reporter/score_table.rb +8 -7
  32. data/lib/saboteur/reporter/survivor_classification.rb +26 -8
  33. data/lib/saboteur/reporter/survivor_hints.rb +3 -2
  34. data/lib/saboteur/reporter/survivor_list.rb +53 -3
  35. data/lib/saboteur/reporter/terminal.rb +27 -29
  36. data/lib/saboteur/reporter/wording.rb +37 -0
  37. data/lib/saboteur/result.rb +10 -0
  38. data/lib/saboteur/text.rb +6 -0
  39. data/lib/saboteur/version.rb +1 -1
  40. metadata +6 -1
@@ -125,7 +125,7 @@ module Saboteur
125
125
  # unscoped_digests (subject id => every digest generated before a diff
126
126
  # scope cut) keeps a scoped run from calling a scoped-out entry stale
127
127
  # (#284).
128
- def apply(results, unscoped_digests: nil)
128
+ def apply(results, subjects: nil, unscoped_digests: nil, full_run: false)
129
129
  seen = {}
130
130
  results.each do |result|
131
131
  key = [result.subject.id, result.digest]
@@ -139,7 +139,8 @@ module Saboteur
139
139
  result.triage_reason = entry.reason
140
140
  result.ignored = entry.reason if entry.state != "todo"
141
141
  end
142
- duplicate_warnings + stale_warnings(results, seen, unscoped_digests)
142
+ duplicate_warnings + stale_warnings(results, seen, unscoped_digests) +
143
+ missing_subject_warnings(results, subjects, full_run: full_run)
143
144
  end
144
145
 
145
146
  private
@@ -159,6 +160,25 @@ module Saboteur
159
160
  .map { |(subject, mutation), _| "duplicate ignore entry #{subject} #{mutation}: the last entry wins" }
160
161
  end
161
162
 
163
+ # A full run examines every in-scope subject, so an entry whose subject
164
+ # never appears points at code that was renamed or removed - say so, but
165
+ # only then: a scoped (--since/--subject) run cannot tell "renamed" from
166
+ # "not in this diff" (#1206). The run's subject list is the source of
167
+ # truth when given: an examined subject with no results (uncovered, or
168
+ # nothing mutable left) is not missing.
169
+ def missing_subject_warnings(results, subjects, full_run:)
170
+ return [] unless full_run
171
+
172
+ examined = (subjects || results).to_set { |item| item.respond_to?(:id) ? item.id : item.subject.id }
173
+ entries.reject { |entry| examined.include?(entry.subject) }
174
+ .map { |entry| missing_subject_message(entry) }
175
+ end
176
+
177
+ def missing_subject_message(entry)
178
+ "stale ignore entry #{entry.subject} #{entry.mutation}: subject not found " \
179
+ "(renamed, removed, or its file is excluded?) - remove the entry or fix the subject name"
180
+ end
181
+
162
182
  def stale_message(entry)
163
183
  "stale ignore entry #{entry.subject} #{entry.mutation}: no such mutation " \
164
184
  "- remove the entry or refresh its digest (saboteur survivors --show)"
@@ -13,6 +13,7 @@ require_relative "pair_removal"
13
13
  require_relative "element_removal"
14
14
  require_relative "endpoint_replacement"
15
15
  require_relative "operator_registry"
16
+ require_relative "operator_explain"
16
17
  require_relative "range_replacement"
17
18
  require_relative "operator_assignment"
18
19
  require_relative "condition_negation"
@@ -0,0 +1,181 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "operator_examples"
5
+ require_relative "operator_registry"
6
+
7
+ module Saboteur
8
+ module MutationSource
9
+ # Curated text behind `saboteur explain NAME`. The definition and the
10
+ # example stay shared with --list-operators (one wording source); the
11
+ # survival and kill hints below are the only text owned here. Every
12
+ # registry operator needs an entry: the coverage spec fails the build
13
+ # when one ships without it.
14
+ module OperatorExplain
15
+ Entry = Data.define(:name, :definition, :example, :survives, :kill)
16
+
17
+ GUIDANCE = {
18
+ "body_nil" => {
19
+ survives: "the method is called for side effects and no test asserts the return value.",
20
+ kill: "an assertion on the exact return value, or on a caller behaviour that depends on it."
21
+ },
22
+ "body_true" => {
23
+ survives: "tests never check the predicate outcome, or only take the path true already forces.",
24
+ kill: "an example whose input makes the predicate false, asserting that path is taken."
25
+ },
26
+ "body_false" => {
27
+ survives: "tests never check the predicate outcome, or only take the path false already forces.",
28
+ kill: "an example whose input makes the predicate true, asserting that path is taken."
29
+ },
30
+ "body_raise" => {
31
+ survives: "tests stub or skip the method, or wrap the call in a broad rescue.",
32
+ kill: "any test that calls the method and lets it complete; the raise fails that example."
33
+ },
34
+ "comparison" => {
35
+ survives: "tests never use values at the boundary the change moves, such as equal values for < vs <=.",
36
+ kill: "a boundary example: the equal case, or the two values just either side of the operator."
37
+ },
38
+ "arithmetic" => {
39
+ survives: "tests assert the result loosely (type, presence, sign) instead of its exact value.",
40
+ kill: "an assertion on the exact computed value with operands where the two operators differ."
41
+ },
42
+ "boolean_logic" => {
43
+ survives: "tests never exercise the dropped operand deciding the outcome on its own.",
44
+ kill: "one example per operand where that operand alone flips the result, asserting each outcome."
45
+ },
46
+ "condition_true" => {
47
+ survives: "tests only exercise the branch the forced condition takes.",
48
+ kill: "an example with a falsy input, asserting the else branch still runs."
49
+ },
50
+ "condition_false" => {
51
+ survives: "tests only exercise the branch the forced condition takes.",
52
+ kill: "an example with a truthy input, asserting the then branch still runs."
53
+ },
54
+ "negation_removal" => {
55
+ survives: "tests assert loosely, or never cover the input the negation inverts.",
56
+ kill: "assert the behaviour for both a truthy and a falsy input through the public API."
57
+ },
58
+ "literal_number" => {
59
+ survives: "the literal flows into nothing a test asserts, or every fixture value works for both.",
60
+ kill: "an assertion on the exact value the literal feeds (a total, a limit, an index)."
61
+ },
62
+ "or_assign" => {
63
+ survives: "tests only cover the nil or false case, where both forms assign.",
64
+ kill: "an example where the variable is already truthy and must keep its value."
65
+ },
66
+ "and_assign" => {
67
+ survives: "tests only cover the truthy case, where both forms assign.",
68
+ kill: "an example where the variable is falsy and must stay untouched."
69
+ },
70
+ "safe_navigation" => {
71
+ survives: "the receiver is never nil in any covering test.",
72
+ kill: "an example with a nil receiver asserting nil comes back instead of NoMethodError."
73
+ },
74
+ "statement_removal" => {
75
+ survives: "the removed statement's effect is never observed: an unasserted side effect or return.",
76
+ kill: "assert the side effect (a spy, a state change) or the value the statement produced."
77
+ },
78
+ "copy_replacement" => {
79
+ survives: "tests never mutate the returned object, so the aliasing goes unnoticed.",
80
+ kill: "mutate what the method returns and assert the original is unchanged (or assert a copy)."
81
+ },
82
+ "filter_replacement" => {
83
+ survives: "tests assert loosely, or the fixture collection is empty or all one kind.",
84
+ kill: "assert the exact surviving elements of a mixed collection, in order."
85
+ },
86
+ "literal_string" => {
87
+ survives: "no test asserts the string's content, only that something was returned.",
88
+ kill: "an exact string equality assertion on the value that carries the literal."
89
+ },
90
+ "literal_boolean" => {
91
+ survives: "the boolean is never asserted, or both values pass the covering assertions.",
92
+ kill: "an example whose expected behaviour flips with the boolean."
93
+ },
94
+ "argument_swap" => {
95
+ survives: "tests use equal or interchangeable arguments, or assert the result loosely.",
96
+ kill: "asymmetric arguments with an assertion on the exact result order."
97
+ },
98
+ "argument_removal" => {
99
+ survives: "the removed argument changes nothing the covering tests observe.",
100
+ kill: "a case where that argument changes the result, asserted exactly."
101
+ },
102
+ "pair_removal" => {
103
+ survives: "tests never read the removed key, directly or through a consumer.",
104
+ kill: "assert the value stored under the removed key, or the whole hash."
105
+ },
106
+ "element_removal" => {
107
+ survives: "tests never exercise the removed element of the array.",
108
+ kill: "an example per element whose behaviour changes when that element is missing."
109
+ },
110
+ "endpoint_replacement" => {
111
+ survives: "fixture collections have one element, or the wrong end is never asserted.",
112
+ kill: "a multi-element collection with an assertion on exactly which end comes back."
113
+ },
114
+ "chain_removal" => {
115
+ survives: "in every tested case the skipped call returns an equivalent value.",
116
+ kill: "a case where the removed call changes the value, asserted exactly."
117
+ },
118
+ "range" => {
119
+ survives: "tests never use the endpoint the exclusive range drops.",
120
+ kill: "an example whose input is the boundary value, asserting it is included."
121
+ },
122
+ "operator_assignment" => {
123
+ survives: "tests check the variable changed, not its exact new value.",
124
+ kill: "assert the exact value after the assignment, where += and -= disagree."
125
+ },
126
+ "condition_negation" => {
127
+ survives: "tests only cover the side of the predicate the negation keeps.",
128
+ kill: "assert both branches with inputs on each side of the predicate."
129
+ },
130
+ "method_swap" => {
131
+ survives: "tests assert loosely, or min and max coincide in every fixture.",
132
+ kill: "a fixture where the two methods differ, asserting the exact expected one."
133
+ },
134
+ "operand_replacement" => {
135
+ survives: "tests assert the result loosely (present, positive, non-nil) or never exercise " \
136
+ "the removed operand.",
137
+ kill: "an assertion on the exact value through the public API, with inputs where the " \
138
+ "removed operand changes the result."
139
+ },
140
+ "interpolation_removal" => {
141
+ survives: "no test asserts the full interpolated content of the string.",
142
+ kill: "an exact string assertion with a known value in the interpolation."
143
+ },
144
+ "jump_value" => {
145
+ survives: "callers ignore the returned value, or tests never take that return path.",
146
+ kill: "an example that takes the early return and asserts the exact value it hands back."
147
+ },
148
+ "rescue_removal" => {
149
+ survives: "tests never raise the error the handler rescues.",
150
+ kill: "an example raising that error, asserting the handler's fallback behaviour."
151
+ },
152
+ "ensure_removal" => {
153
+ survives: "the cleanup's effect is never observed on the tested paths.",
154
+ kill: "assert the cleanup ran (resource released, flag reset), including on the error path."
155
+ },
156
+ "when_removal" => {
157
+ survives: "tests never hit the removed branch of the case.",
158
+ kill: "one example per when branch, asserting its exact outcome."
159
+ },
160
+ "unary_removal" => {
161
+ survives: "fixtures use zero or symmetric values, or tests assert loosely.",
162
+ kill: "an assertion on the exact signed or complemented value with a non-trivial input."
163
+ },
164
+ "regex_replacement" => {
165
+ survives: "every test input matches under both the original and the loosened regex.",
166
+ kill: "an input the anchor or quantifier excludes, asserting it is rejected."
167
+ }
168
+ }.freeze
169
+
170
+ # nil for an unknown name; the command turns that into the exit-2 error.
171
+ def self.for(name)
172
+ return unless OperatorRegistry::DESCRIPTIONS.key?(name)
173
+
174
+ guidance = GUIDANCE.fetch(name)
175
+ Entry.new(name:, definition: OperatorRegistry::DESCRIPTIONS.fetch(name),
176
+ example: OperatorExamples::EXAMPLES.fetch(name),
177
+ survives: guidance.fetch(:survives), kill: guidance.fetch(:kill))
178
+ end
179
+ end
180
+ end
181
+ end
@@ -45,14 +45,30 @@ module Saboteur
45
45
 
46
46
  # Replay one survivor by digest in the same selection. capped: false
47
47
  # skips the protocol byte cap, for callers that print to a terminal.
48
- def replay(provenance, digest, capped: true)
49
- words = ["saboteur", "run", "--id", digest, *selection(provenance, replay: true), "--seed",
50
- provenance.seed.to_s]
48
+ # report: narrows the replay to the mutant's covering spec files
49
+ # instead of the run's full inferred list (#1148).
50
+ def replay(provenance, digest, capped: true, report: nil)
51
+ words = ["saboteur", "run", "--id", digest,
52
+ *selection(provenance, replay: true, tests: report && covering_files(report, digest)),
53
+ "--seed", provenance.seed.to_s]
51
54
  finish(words, provenance, capped: capped)
52
55
  end
53
56
 
54
57
  private
55
58
 
59
+ # A replay needs only the specs that cover the mutant, not every
60
+ # inferred file (#1148): covered_by holds RSpec ids like
61
+ # "./spec/fee_spec.rb[1:2]", so strip the example suffix. nil when
62
+ # the survivor has no covering ids - then the whole selection
63
+ # stays, since dropping it would widen the replay to inference.
64
+ def covering_files(report, digest)
65
+ result = report.results.find { |entry| entry.digest == digest }
66
+ return unless result
67
+
68
+ files = result.covered_by.filter_map { |id| id[%r{\A\.?/?(.+?\.rb)\[}, 1] }.uniq
69
+ files unless files.empty?
70
+ end
71
+
56
72
  def finish(words, provenance, capped: true)
57
73
  words += ["--", *provenance.sources] unless provenance.sources.empty?
58
74
  command = Shellwords.join(words)
@@ -93,13 +109,14 @@ module Saboteur
93
109
 
94
110
  # Whole-suite scope is a bare --all, only when no tests resolved;
95
111
  # --all never combines with --test.
96
- def selection(provenance, replay: false)
112
+ def selection(provenance, replay: false, tests: nil)
113
+ tests ||= provenance.tests
97
114
  words = []
98
115
  words.push("--config", provenance.config_path) if provenance.config_path
99
116
  words.push("--since", provenance.ref) if provenance.ref
100
117
  provenance.subjects.each { |subject| words.push("--subject", subject) }
101
- words << "--all" if provenance.settings[:all] && provenance.tests.empty?
102
- words.push(*test_words(provenance.tests))
118
+ words << "--all" if provenance.settings[:all] && tests.empty?
119
+ words.push(*test_words(tests))
103
120
  words.concat(setting_words(provenance, replay: replay))
104
121
  words.concat(mode_words(provenance))
105
122
  words.push("--id", provenance.digest) if provenance.digest && !replay
@@ -40,8 +40,7 @@ module Saboteur
40
40
  add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
41
41
  add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
42
42
  add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
43
- add(reasons, "unverified", report.counts[:unverified],
44
- "mutants the --max-duration budget left unverified; their kills are not confirmed")
43
+ unverified_reasons(reasons, report)
45
44
  discovery_failures(reasons, report)
46
45
  early_stop_reason(reasons, report)
47
46
  inline_scope(reasons, report)
@@ -70,6 +69,14 @@ module Saboteur
70
69
  "included untracked files could not be read; their subjects were not covered")
71
70
  end
72
71
 
72
+ def unverified_reasons(reasons, report)
73
+ flaky = report.results.count(&:flaky?)
74
+ add(reasons, "unverified", report.counts[:unverified] - flaky,
75
+ "mutants the --max-duration budget left unverified; their kills are not confirmed")
76
+ add(reasons, "flaky", flaky,
77
+ "mutants gave a different verdict on the --flaky-check reordered rerun; they are unverified")
78
+ end
79
+
73
80
  def early_stop_reason(reasons, report)
74
81
  stop = report.early_stop
75
82
  return unless stop
@@ -123,7 +123,8 @@ module Saboteur
123
123
  steps = {}
124
124
  add_command(steps, :rerun, Commands.rerun(provenance))
125
125
  first = Findings.build(report)[:survivors].first
126
- add_command(steps, :replay, Commands.replay(provenance, first[:digest])) if first
126
+ # report: narrows the replay to covering specs, as on the run screen (#1148).
127
+ add_command(steps, :replay, Commands.replay(provenance, first[:digest], report: report)) if first
127
128
  steps[:fullReport] = provenance.json_report if short_path?(provenance.json_report)
128
129
  steps
129
130
  end
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # The gap-kind vocabulary (D2): which sort of check a survivor suggests
7
+ # is missing, as a LABEL ONLY (#192) - a heuristic from the operator and
8
+ # the edit shape, never a diagnosis, and never a score input.
9
+ module GapKinds
10
+ KINDS = %w[boundary branch effect number] + ["error path", "result unchecked"].freeze
11
+
12
+ # Operator -> gap kind, every built-in operator exactly once except
13
+ # operand_replacement, whose kind depends on the replaced expression
14
+ # (a comparison loses its boundary; anything else is a plain result).
15
+ # Anything missing here - a new operator before its row lands - is
16
+ # the honest fallback: result unchecked.
17
+ BY_OPERATOR = {
18
+ "comparison" => "boundary",
19
+ "range" => "boundary",
20
+ "endpoint_replacement" => "boundary",
21
+ "condition_negation" => "branch",
22
+ "condition_true" => "branch",
23
+ "condition_false" => "branch",
24
+ "boolean_logic" => "branch",
25
+ "negation_removal" => "branch",
26
+ "when_removal" => "branch",
27
+ "safe_navigation" => "branch",
28
+ "filter_replacement" => "branch",
29
+ "statement_removal" => "effect",
30
+ "element_removal" => "effect",
31
+ "argument_removal" => "effect",
32
+ "pair_removal" => "effect",
33
+ "chain_removal" => "effect",
34
+ "copy_replacement" => "effect",
35
+ "literal_number" => "number",
36
+ "arithmetic" => "number",
37
+ "unary_removal" => "number",
38
+ "operator_assignment" => "number",
39
+ "rescue_removal" => "error path",
40
+ "ensure_removal" => "error path",
41
+ "body_raise" => "error path",
42
+ "body_nil" => "result unchecked",
43
+ "body_true" => "result unchecked",
44
+ "body_false" => "result unchecked",
45
+ "literal_string" => "result unchecked",
46
+ "literal_boolean" => "result unchecked",
47
+ "or_assign" => "result unchecked",
48
+ "and_assign" => "result unchecked",
49
+ "argument_swap" => "result unchecked",
50
+ "method_swap" => "result unchecked",
51
+ "interpolation_removal" => "result unchecked",
52
+ "jump_value" => "result unchecked",
53
+ "regex_replacement" => "result unchecked"
54
+ }.freeze
55
+
56
+ # The one operator whose kind comes from the edit shape, not the name.
57
+ SHAPE_DECIDED = %w[operand_replacement].freeze
58
+
59
+ FALLBACK = "result unchecked"
60
+
61
+ # The deterministic per-kind reason printed next to a survivor's rank.
62
+ REASONS = {
63
+ "boundary" => "the covering specs pass without pinning this boundary",
64
+ "branch" => "the covering specs pass without pinning this branch",
65
+ "effect" => "the covering specs pass without pinning this effect",
66
+ "number" => "the covering specs pass without pinning this number",
67
+ "error path" => "the covering specs pass without pinning this error path",
68
+ "result unchecked" => "the covering specs pass without pinning this result"
69
+ }.freeze
70
+
71
+ module_function
72
+
73
+ def kind(operator, comparison_edit: false)
74
+ return comparison_edit ? "boundary" : FALLBACK if SHAPE_DECIDED.include?(operator)
75
+
76
+ BY_OPERATOR.fetch(operator, FALLBACK)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -6,6 +6,7 @@ require "fileutils"
6
6
 
7
7
  require_relative "../survivor_guidance"
8
8
  require_relative "survivor_classification"
9
+ require_relative "wording"
9
10
 
10
11
  module Saboteur
11
12
  module Reporter
@@ -151,11 +152,13 @@ module Saboteur
151
152
  def headline(report)
152
153
  breakdown = report.score_breakdown
153
154
  text = "<p><strong>Covered mutation score: #{score_text(report.score)}</strong>"
154
- text << " (#{breakdown.detected}/#{breakdown.tested} killed, tested subjects)" if breakdown.tested.positive?
155
+ if breakdown.tested.positive?
156
+ text << " (#{breakdown.detected}/#{breakdown.tested} #{Wording.detected_word(breakdown)}, tested subjects)"
157
+ end
155
158
  untested = report.skipped_subjects.count { |_, reason| reason == ScoreTable::NO_COVERAGE }
156
159
  if untested.positive?
157
160
  noun = untested == 1 ? "subject" : "subjects"
158
- text << " - #{untested} #{noun} not scored - no tests exist"
161
+ text << " - #{untested} #{noun} not scored - #{Wording.no_execution(untested)}"
159
162
  end
160
163
  "#{text}</p>"
161
164
  end
@@ -187,13 +190,18 @@ module Saboteur
187
190
  "the score covers those changes, not the whole project.</p>"
188
191
  end
189
192
 
193
+ # "1 subject, 2 mutants": each count pluralizes on its own (C9).
194
+ def counts_note(report)
195
+ "#{Text.counted(report.subjects.size, 'subject')}, #{Text.counted(report.results.size, 'mutant')}"
196
+ end
197
+
190
198
  def summary(report, data)
191
199
  seed = data.dig(:config, :seed)
192
200
  seed_note = seed ? ", seed #{seed}" : ""
193
201
  counts = data[:files].values.flat_map { |entry| entry[:mutants] }.map { |mutant| mutant[:status] }.tally
194
202
  <<~HTML
195
203
  <p><strong>Covered mutation score: #{score_text(report.score)}</strong>
196
- (#{report.subjects.size} subjects, #{report.results.size} mutants, #{format('%.1f', report.wall_time)}s#{seed_note})</p>
204
+ (#{counts_note(report)}, #{format('%.1f', report.wall_time)}s#{seed_note})</p>
197
205
  <p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
198
206
  mutants are not counted. Survivors accepted on the ignore list stay in the denominator. It covers only the subjects of this run, not the whole project. A tool that reads the
199
207
  Stryker JSON may show a different score, because the schema counts NoCoverage as undetected.</p>
@@ -310,7 +318,10 @@ module Saboteur
310
318
 
311
319
  def file_section(file, entry, names, results, report, survivor_count:, open_file:)
312
320
  open = open_file ? " open" : ""
313
- ordered = entry[:mutants].sort_by { |mutant| position_key(mutant) }
321
+ # Attention rows (survivors, timeouts, errors) come first, so a big
322
+ # file's survivors are not buried between killed rows; position
323
+ # order holds within each group (#1233).
324
+ ordered = entry[:mutants].sort_by { |mutant| [attention_rank(mutant), *position_key(mutant)] }
314
325
  rows = ordered.map { |mutant| mutant_row(mutant, names, results[[file, mutant[:id]]], report) }
315
326
  "<details class=\"file\"#{open}><summary>#{escape(file)} " \
316
327
  "(#{entry[:mutants].size} mutants#{survivors_note(survivor_count, open_file)})</summary>\n" \
@@ -330,6 +341,10 @@ module Saboteur
330
341
  open_file || survivor_count.zero? ? "" : ", #{survivor_count} survivor#{'s' unless survivor_count == 1}"
331
342
  end
332
343
 
344
+ def attention_rank(mutant)
345
+ ATTENTION_STATUSES.include?(mutant[:status]) ? 0 : 1
346
+ end
347
+
333
348
  def position_key(mutant)
334
349
  start = mutant[:location][:start]
335
350
  [start[:line], start[:column], mutant[:id]]
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "gap_kinds"
5
+
4
6
  module Saboteur
5
7
  module Reporter
6
8
  # Pull-request review comments for surviving mutants (#246): decides what
@@ -25,11 +27,10 @@ module Saboteur
25
27
  # Labels whose survivors are probably noise: never worth a comment.
26
28
  QUIET = %w[likely_equivalent arid].freeze
27
29
 
28
- HINTS = {
29
- "missing_test" => "A test reaches this line and still passes with the change. " \
30
- "Add an assertion that pins this behaviour.",
31
- "tool_limit" => "Saboteur could not observe this mutant faithfully. Check it by hand."
32
- }.freeze
30
+ GAP_HINT = "A test reaches this line and still passes with the change. " \
31
+ "Add an assertion that pins this behaviour."
32
+ TOOL_LIMIT_HINT = "Saboteur could not observe this mutant faithfully. Check it by hand."
33
+ HINTS = GapKinds::KINDS.to_h { |label| [label, GAP_HINT] }.merge("tool_limit" => TOOL_LIMIT_HINT).freeze
33
34
  DEFAULT_HINT = "No test fails with this change. Check whether a test should pin it."
34
35
 
35
36
  Plan = Struct.new(:post, :delete, :summary, keyword_init: true)
@@ -1,6 +1,7 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "wording"
4
5
  require_relative "../score"
5
6
 
6
7
  module Saboteur
@@ -62,7 +63,7 @@ module Saboteur
62
63
  detected: breakdown.detected, tested: breakdown.tested, untested: untested }
63
64
  end
64
65
 
65
- # "cart.rb 100.0% (2/2) · n/a - 2 subjects have no tests: ...": the
66
+ # "cart.rb 100.0% (2/2) · n/a - 2 subjects - no test executes them: ...": the
66
67
  # tested score and the untested count stand next to each other; a
67
68
  # file with nothing tested gets no percentage at all.
68
69
  def line(row, width)
@@ -74,18 +75,18 @@ module Saboteur
74
75
  note ? "#{base} #{@palette.glyph(:middot)} #{note}" : base
75
76
  end
76
77
 
77
- # "n/a - 2 subjects have no tests: Cart#total, Cart#biggest": names
78
- # the subjects the score cannot speak for, capped so a long list does
79
- # not swallow the row; the Not scored block lists them all.
78
+ # "n/a - 2 subjects - no test executes them: Cart#total, Cart#biggest":
79
+ # names the subjects the score cannot speak for, capped so a long
80
+ # list does not swallow the row; the Not scored block lists them all.
81
+ # Wording owns the claim (#1145): a stubbed-away subject has tests,
82
+ # they just never execute it.
80
83
  def untested_note(untested)
81
84
  return if untested.empty?
82
85
 
83
86
  names = untested.map(&:id)
84
87
  shown = names.first(MAX_UNTESTED_NAMES)
85
88
  shown << "+#{names.size - MAX_UNTESTED_NAMES} more" if names.size > MAX_UNTESTED_NAMES
86
- count = untested.size
87
- @palette.paint(:warn, "n/a - #{count} subject#{'s' unless count == 1} " \
88
- "#{count == 1 ? 'has' : 'have'} no tests: #{shown.join(', ')}")
89
+ @palette.paint(:warn, "n/a - #{Wording.untested_note(untested.size)}: #{shown.join(', ')}")
89
90
  end
90
91
  end
91
92
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "../engine/bytecode_equivalence"
5
+ require_relative "gap_kinds"
5
6
  require_relative "../execution/external_runner"
6
7
  require_relative "../mutation_source/builtin"
7
8
 
@@ -21,11 +22,14 @@ module Saboteur
21
22
  # arid - every line an edit touches matches an arid pattern
22
23
  # (logging and friends, #111/#187). The builtin hint list decides;
23
24
  # a configured arid policy does not change the label.
24
- # missing_test - a test covered the line and passed, which MAY mean
25
- # the suite reaches the code without pinning this behaviour. The
26
- # covering list is only the PLANNED coverage: whole-body (coarse)
27
- # mutants carry method-level guesses, so they never get this
28
- # label, and neither does anything without real coverage.
25
+ # A gap kind (boundary, branch, effect, number, error path, or the
26
+ # result unchecked fallback) - a test covered the line and passed,
27
+ # which MAY mean the suite reaches the code without pinning this
28
+ # behaviour; the kind names WHAT sort of check is missing, from the
29
+ # operator and the edit shape only. The covering list is only the
30
+ # PLANNED coverage: whole-body (coarse) mutants carry method-level
31
+ # guesses, so they never get a gap kind, and neither does anything
32
+ # without real coverage.
29
33
  # tool_limit - survivors saboteur could not observe faithfully: an
30
34
  # external-runner run records only the pseudo-test id, never real
31
35
  # coverage (#1071). A re-eval refusal records no signal on the
@@ -33,7 +37,7 @@ module Saboteur
33
37
  #
34
38
  # A survivor with no matching signal stays unlabeled.
35
39
  module SurvivorClassification
36
- LABELS = %w[likely_equivalent arid missing_test tool_limit].freeze
40
+ LABELS = %w[likely_equivalent arid tool_limit].concat(GapKinds::KINDS).freeze
37
41
 
38
42
  class << self
39
43
  def label(result, arid: MutationSource::Builtin::DEFAULT_ARID)
@@ -41,7 +45,7 @@ module Saboteur
41
45
  return "likely_equivalent" if equivalent?(result)
42
46
  return "arid" if arid?(result, arid)
43
47
  return "tool_limit" if tool_limited?(result)
44
- return "missing_test" if possible_test_gap?(result)
48
+ return gap_kind(result) if possible_test_gap?(result)
45
49
 
46
50
  nil
47
51
  end
@@ -66,11 +70,25 @@ module Saboteur
66
70
 
67
71
  # An external runner reports one pseudo id for every mutant and no
68
72
  # coverage map, so a survivor there was never observed against real
69
- # tests. Pseudo id next to a real id keeps missing_test (#305).
73
+ # tests. Pseudo id next to a real id keeps the gap kind (#305).
70
74
  def tool_limited?(result)
71
75
  result.covered_by.any? && result.covered_by.all? { |id| id == Execution::ExternalRunner::PSEUDO_TEST }
72
76
  end
73
77
 
78
+ # The gap kind says which sort of check the survivor suggests:
79
+ # the operator decides for almost everything; operand_replacement
80
+ # looks at the replaced expression (comparison -> boundary).
81
+ def gap_kind(result)
82
+ GapKinds.kind(result.mutation.operator, comparison_edit: comparison_edit?(result))
83
+ end
84
+
85
+ def comparison_edit?(result)
86
+ result.mutation.edits.any? do |edit|
87
+ result.subject.file_source.byteslice(edit.start_byte...edit.end_byte)
88
+ &.match?(/<=>|<=|>=|==|!=|===|<|>/)
89
+ end
90
+ end
91
+
74
92
  # covered_by is a PLANNED covering list, not a verdict about what
75
93
  # the suite asserts: coarse (whole-body) mutants carry method-level
76
94
  # guesses, and a --test-command run records only the pseudo-test id
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "gap_kinds"
5
+
4
6
  module Saboteur
5
7
  module Reporter
6
8
  # Suggests a check that might kill a surviving mutant, by operator. It does
@@ -94,9 +96,8 @@ module Saboteur
94
96
  LABEL_REASONS = {
95
97
  "likely_equivalent" => "compiles to the same instructions as the original",
96
98
  "arid" => "every edited line matches an arid pattern",
97
- "missing_test" => "the covering specs pass without pinning this change",
98
99
  "tool_limit" => "the tool could not observe this mutant faithfully"
99
- }.freeze
100
+ }.merge(GapKinds::REASONS).freeze
100
101
 
101
102
  module_function
102
103