saboteur 0.12.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce718289117e022594f50dfb13bfaf3feacf03f99dc1a5b73c7c2463581d24f2
4
- data.tar.gz: f99866dbeacb50607ac66ee9f15582b49b1596b318cba1598afdb7bac89a9133
3
+ metadata.gz: 19bac537a82f6aa32ade9a7a1955cd793793d5a95b026dc56274c7c97560cfa6
4
+ data.tar.gz: 40086d139cbb82e418c9b7de5aa31b2aedd19eeb61ae238ba01f80c0e3f0d5b4
5
5
  SHA512:
6
- metadata.gz: 2ef5f5dc4d8d441d35558569bd5649bc4d7bbece07bde9172bd6d660a8d54e0274cc02b36f2968f4e129080cc3ccc3c5bdb3a091c0fbe55341a67d53bf967540
7
- data.tar.gz: 9f6d1c79c63abc5d314f79f699bb9ccfa9de5bdb2a8c7d4240d311854774c4eb4c3c40950234f206bb62872008b5525181bd20b13325b080f680ebc4227ad6e5
6
+ metadata.gz: 1dda55357cc1866e39f052b3b69424e6dde81ec551b695822e35e58a5ec65fe00b0e795b650338c12e2a2869e4a046f3a6e0839742e86bb3bd7ae52db809e124
7
+ data.tar.gz: f094725207c45c6b5c73d848790f2a6d8a91e849dcd281eabf616c6de414fbf6f9f9f589c7a15c0d6e15c72d1e21971be4a2edc19802691bc2c508b965fe99e9
data/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.3] - 2026-10-22
10
+
11
+ ### Added
12
+
13
+ - Survivors are labeled `input_gap` or `assertion_gap` from the value the
14
+ mutated expression actually produced (side-effect-free sites only).
15
+ `input_gap` means the tests never gave the expression an input where its
16
+ value differs, or no test reached it. `assertion_gap` means the value
17
+ changed and the tests still passed. The JSON report gains `valueDiffed`
18
+ and `gapKind` fields (#1290, #1305, #1306, #1307).
19
+
20
+ ### Changed
21
+
22
+ - The cache stores the observed value with each verdict (cache format 2),
23
+ so cached results from before this release are rebuilt once on the next
24
+ run (#1290, #1307).
25
+
9
26
  ## [0.12.2] - 2026-10-22
10
27
 
11
28
  ### Added
data/README.md CHANGED
@@ -80,8 +80,10 @@ tree refuses with exit 2 and points at the generator. Engine gem with
80
80
  assertion, 1 by a crash`), and an error-dominated run prints
81
81
  `Score: n/a` instead of a number you would misread.
82
82
  - 🔍 **Survivors you can act on.** Survivors group per statement with a
83
- triage label (`likely_equivalent`, `arid`, `tool_limit`, `input_gap` when no test
84
- ran the mutant, or a gap kind) and their
83
+ triage label (`likely_equivalent`, `arid`, `tool_limit`, `input_gap` (the tests
84
+ never gave the mutated expression an input where its value differs, or no
85
+ test reached it), `assertion_gap` (the value changed and the tests still
86
+ passed), or a gap kind) and their
85
87
  covering tests - split into the tests that ran against the mutant and
86
88
  the ones that never did. `saboteur survivors` browses them read-only:
87
89
  one digest shows the full diff and the exact replay command.
@@ -138,7 +138,7 @@ module Saboteur
138
138
  def detail_lines(survivor, finding)
139
139
  [
140
140
  "#{survivor[:path]}:#{survivor[:line]} #{survivor[:operator]} #{survivor[:digest]}",
141
- *classification_line(survivor[:classification]),
141
+ *classification_line(survivor[:classification], survivor[:gap_kind]),
142
142
  "",
143
143
  "- #{finding['original'] || original_line(survivor)}",
144
144
  "+ #{finding['replacement'] || survivor[:replacement]}",
@@ -149,10 +149,10 @@ module Saboteur
149
149
  end
150
150
 
151
151
  # The same wording the terminal survivor block uses (#1183).
152
- def classification_line(label)
152
+ def classification_line(label, kind = nil)
153
153
  return [] unless label
154
154
 
155
- reason = Reporter::SurvivorHints.label_reason(label)
155
+ reason = Reporter::SurvivorHints.label_reason(label, kind: kind)
156
156
  ["classification: #{label}#{" - #{reason}" if reason}"]
157
157
  end
158
158
 
@@ -241,7 +241,8 @@ module Saboteur
241
241
  entry.fetch("mutants", []).select { |m| m["status"] == "Survived" }.map do |m|
242
242
  { path: path, line: m.dig("location", "start", "line").to_i, operator: m["mutatorName"],
243
243
  digest: m["id"], replacement: m["replacement"], classification: m["classification"],
244
- subject: m["subject"], covered: m["coveredBy"] || [], source: entry["source"] }
244
+ gap_kind: m["gapKind"], subject: m["subject"], covered: m["coveredBy"] || [],
245
+ source: entry["source"] }
245
246
  end
246
247
  end
247
248
  survivors
@@ -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)
@@ -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
@@ -98,6 +101,7 @@ module Saboteur
98
101
  def run(subject:, text:, test_ids:, timeout:, mutation: nil, eval_prefix: nil)
99
102
  @hooks.run(:before_fork, mutation)
100
103
  @sequence = @sequence.to_i + 1
104
+ text = SiteProbe.text(subject, mutation, text) || text if mutation
101
105
  job = Job.new(subject:, text:, test_ids:, timeout:, mutation:, eval_prefix:,
102
106
  log_path: File.join(@log_dir, "child-#{Process.pid}-#{@sequence}.log"))
103
107
  log_file = open_log(job)
@@ -132,6 +136,7 @@ module Saboteur
132
136
  # Before the evaluation: a whole-file subject runs the file's own
133
137
  # top-level code there, and it must see the run's random numbers.
134
138
  Kernel.srand(@seed)
139
+ SiteProbe.reset
135
140
  evaluate_mutant(writer, job)
136
141
  stale = stale_captures(job)
137
142
  Protocol.emit(writer, "captures", site: stale.first, stale: stale.size) if stale.any?
@@ -170,7 +175,8 @@ module Saboteur
170
175
  Protocol.emit(writer, "error", message: "around_mutant hook did not run the tests")
171
176
  else
172
177
  called = flags[:replacement_called]
173
- 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)
174
180
  # A reload after the mutant already ran (an after-hook restore)
175
181
  # is no reason to doubt the verdict; only a never-invoked mutant
176
182
  # can have been hidden by the overwrite.
@@ -394,7 +400,8 @@ module Saboteur
394
400
  return mismatch(runtime, "executed #{executed.size} of #{test_ids.size} requested tests")
395
401
  end
396
402
 
397
- 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"])
398
405
  end
399
406
 
400
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
@@ -71,7 +71,8 @@ module Saboteur
71
71
  edits: entries,
72
72
  coarse: result.mutation.coarse,
73
73
  # Why the survivor is worth a look, as a label only (#192).
74
- classification: SurvivorClassification.label(result)
74
+ classification: SurvivorClassification.label(result),
75
+ gapKind: SurvivorClassification.kind(result)
75
76
  }.compact
76
77
  entry = with_changed_lines(report, result, entry)
77
78
  result.ignored ? entry.merge(reason: result.ignored) : entry
@@ -74,6 +74,18 @@ module Saboteur
74
74
  "result unchecked" => "the covering specs pass without pinning this result"
75
75
  }.freeze
76
76
 
77
+ # What an observed label (input_gap/assertion_gap, #1290) replaces
78
+ # the kind with, and the advice the kind gave: kept in the reason.
79
+ OBSERVED = %w[input_gap assertion_gap].freeze
80
+ ADVICE = {
81
+ "boundary" => "exercise both sides of this boundary",
82
+ "branch" => "exercise both branches",
83
+ "effect" => "assert the effect of this statement",
84
+ "number" => "no test depends on this exact number",
85
+ "error path" => "exercise the error path",
86
+ "result unchecked" => "assert this result"
87
+ }.freeze
88
+
77
89
  module_function
78
90
 
79
91
  def kind(operator, comparison_edit: false)
@@ -32,7 +32,10 @@ module Saboteur
32
32
  TOOL_LIMIT_HINT = "Saboteur could not observe this mutant faithfully. Check it by hand."
33
33
  HINTS = GapKinds::KINDS.to_h { |label| [label, GAP_HINT] }
34
34
  .merge("tool_limit" => TOOL_LIMIT_HINT,
35
- "input_gap" => "No test reaches this line. Exercise it with a varied input.").freeze
35
+ "input_gap" => "No test input makes this change differ. " \
36
+ "Exercise it with a varied input.",
37
+ "assertion_gap" => "A test reaches this line, the value changes, and it still " \
38
+ "passes. Add an assertion that pins this behaviour.").freeze
36
39
  DEFAULT_HINT = "No test fails with this change. Check whether a test should pin it."
37
40
 
38
41
  Plan = Struct.new(:post, :delete, :summary, keyword_init: true)
@@ -139,9 +139,14 @@ module Saboteur
139
139
  # Why the survivor is worth a look, as a label only: scores and
140
140
  # the exit code never change (#192).
141
141
  classification: SurvivorClassification.label(result),
142
+ # Additive (#1290): the gap kind an observed label replaced.
143
+ gapKind: SurvivorClassification.kind(result),
142
144
  # Additive (#1286): confirmed, killed_by_confirmation or unconfirmed.
143
145
  # An unconfirmed survivor reports Ignored: outside the score.
144
146
  confirmation: result.confirmation_state,
147
+ # Additive (#1290): whether the mutated expression ever produced a
148
+ # different value from the original; absent when not probed.
149
+ valueDiffered: result.value_differed,
145
150
  creditedTest: result.credited_test&.then { |name| test_ids.fetch(name) },
146
151
  killedBy: result.killed_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
147
152
  coveredBy: result.covered_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
@@ -29,11 +29,14 @@ module Saboteur
29
29
  # operator and the edit shape only. The covering list is only the
30
30
  # PLANNED coverage: whole-body (coarse) mutants carry method-level
31
31
  # guesses, so they never get a gap kind.
32
- # input_gap - no real test covered the mutant at all, so the input
33
- # never varied (D6). Coarse mutants stay unlabeled: their planned
34
- # coverage is a method-level guess, not an observation. The
35
- # deterministic version (the runner records the mutated value
36
- # actually differing) is #1064 follow-up.
32
+ # input_gap - the tests never gave the mutated expression an input
33
+ # where its value differs (the runner recorded it, #1290), or no
34
+ # real test covered the mutant at all (D6). Coarse mutants stay
35
+ # unlabeled: their planned coverage is a method-level guess.
36
+ # assertion_gap - the mutated expression produced a different value
37
+ # while the covering tests ran, and they still passed: a test
38
+ # reaches the code and no assertion pins it (#1290). Only side-effect
39
+ # free sites are observed; an unprobed site keeps its gap kind.
37
40
  # tool_limit - survivors saboteur could not observe faithfully: an
38
41
  # external-runner run records only the pseudo-test id, never real
39
42
  # coverage (#1071). A re-eval refusal records no signal on the
@@ -41,7 +44,10 @@ module Saboteur
41
44
  #
42
45
  # A survivor with no matching signal stays unlabeled.
43
46
  module SurvivorClassification
44
- LABELS = %w[likely_equivalent arid tool_limit input_gap].concat(GapKinds::KINDS).freeze
47
+ LABELS = %w[likely_equivalent arid tool_limit input_gap assertion_gap].concat(GapKinds::KINDS).freeze
48
+ # assertion_gap decides a statement's label ahead of every gap kind: the
49
+ # value changed and the tests passed.
50
+ GROUP_ORDER = (["assertion_gap"] + GapKinds::PRECEDENCE).freeze
45
51
 
46
52
  class << self
47
53
  def label(result, arid: MutationSource::Builtin::DEFAULT_ARID)
@@ -49,6 +55,9 @@ module Saboteur
49
55
  return "likely_equivalent" if equivalent?(result)
50
56
  return "arid" if arid?(result, arid)
51
57
  return "tool_limit" if tool_limited?(result)
58
+
59
+ observed = observed_gap(result)
60
+ return observed if observed
52
61
  return gap_kind(result) if possible_test_gap?(result)
53
62
  return "input_gap" unless result.mutation.coarse
54
63
 
@@ -67,7 +76,21 @@ module Saboteur
67
76
  # subsumes a missing branch, boundary or effect check on it.
68
77
  def group_label(results)
69
78
  labels = results.map { |result| label(result) }.compact
70
- GapKinds::PRECEDENCE.find { |kind| labels.include?(kind) } || labels.first
79
+ GROUP_ORDER.find { |kind| labels.include?(kind) } || labels.first
80
+ end
81
+
82
+ # The gap kind an observed label (input_gap/assertion_gap) replaced:
83
+ # what the edit shape says is unchecked. nil unless the observation
84
+ # decided the label, so a plain kind label stays its own kind.
85
+ def kind(result)
86
+ return nil unless GapKinds::OBSERVED.include?(observed_gap(result)) && possible_test_gap?(result)
87
+
88
+ gap_kind(result)
89
+ end
90
+
91
+ def group_kind(results)
92
+ kinds = results.map { |result| kind(result) }.compact
93
+ GapKinds::PRECEDENCE.find { |kind| kinds.include?(kind) }
71
94
  end
72
95
 
73
96
  private
@@ -91,6 +114,15 @@ module Saboteur
91
114
  # An external runner reports one pseudo id for every mutant and no
92
115
  # coverage map, so a survivor there was never observed against real
93
116
  # tests. Pseudo id next to a real id keeps the gap kind (#305).
117
+ # The runner's observation (#1290): the mutated expression never gave
118
+ # a different value (input_gap), or gave one and the tests passed
119
+ # (assertion_gap). nil when the site was not probed.
120
+ def observed_gap(result)
121
+ return nil if result.mutation.coarse || result.value_differed.nil?
122
+
123
+ result.value_differed ? "assertion_gap" : "input_gap"
124
+ end
125
+
94
126
  def tool_limited?(result)
95
127
  result.covered_by.any? && result.covered_by.all? { |id| id == Execution::ExternalRunner::PSEUDO_TEST }
96
128
  end
@@ -48,9 +48,8 @@ module Saboteur
48
48
  UNEQUAL_CASE = [%w[< >], %w[<= >=]].map(&:to_set).freeze
49
49
 
50
50
  # Labels that never get a hint: a generated assertion on an equivalent
51
- # mutant is the worst kind of hint noise. The assertion_gap/input_gap
52
- # split (#1064) narrows this further; until it lands, every other
53
- # label keeps the operator hint unchanged.
51
+ # mutant is the worst kind of hint noise. Every other label, including
52
+ # input_gap and assertion_gap, keeps the operator hint.
54
53
  NO_HINT_LABELS = %w[likely_equivalent].freeze
55
54
 
56
55
  # One deterministic template sentence per operator: what the change
@@ -91,13 +90,14 @@ module Saboteur
91
90
 
92
91
  # The deterministic per-label reason printed next to a survivor's rank.
93
92
  # Labels come from SurvivorClassification; they are heuristics, not
94
- # diagnoses. #1064 lands the assertion_gap label: add its reason here
95
- # and its gate in NO_HINT_LABELS when it does.
93
+ # diagnoses. input_gap and assertion_gap keep the operator hint: it is
94
+ # the right place to look in both cases (#1290).
96
95
  LABEL_REASONS = {
97
96
  "likely_equivalent" => "compiles to the same instructions as the original",
98
97
  "arid" => "every edited line matches an arid pattern",
99
98
  "tool_limit" => "the tool could not observe this mutant faithfully",
100
- "input_gap" => "no test reached this mutant, so its input never varied"
99
+ "input_gap" => "no test input made this mutation change the value, so its input never varied",
100
+ "assertion_gap" => "a test ran this mutant, the value changed, and no assertion noticed"
101
101
  }.merge(GapKinds::REASONS).freeze
102
102
 
103
103
  module_function
@@ -114,9 +114,13 @@ module Saboteur
114
114
  WHY_IT_MATTERS.fetch(operator, WHY_FALLBACK)
115
115
  end
116
116
 
117
- # The per-label reason sentence; nil when the label has none.
118
- def label_reason(label)
119
- LABEL_REASONS[label]
117
+ # The per-label reason sentence; nil when the label has none. An
118
+ # observed label (input_gap/assertion_gap) keeps the advice of the gap
119
+ # kind it replaced, so the kind is not lost behind the observation.
120
+ def label_reason(label, kind: nil)
121
+ reason = LABEL_REASONS[label]
122
+ advice = GapKinds::ADVICE[kind]
123
+ advice && GapKinds::OBSERVED.include?(label) ? "#{reason}; #{advice}" : reason
120
124
  end
121
125
 
122
126
  def for(operator, coarse: false)
@@ -78,7 +78,7 @@ module Saboteur
78
78
  path = relative(first.subject.file, report.root)
79
79
  @out.puts "\n#{@palette.paint(:head, "#{rank}. #{path}:#{line}")} #{first.subject.id} " \
80
80
  "#{@palette.paint(:dim, label_suffix(group, rank, total))}".rstrip
81
- reason = SurvivorHints.label_reason(@classifier.group_label(group))
81
+ reason = SurvivorHints.label_reason(@classifier.group_label(group), kind: @classifier.group_kind(group))
82
82
  @out.puts " #{@palette.paint(:dim, reason)}" if reason
83
83
  fence(first, line)
84
84
  @out.puts " #{@palette.paint(:dim, "why it matters: #{why(first.mutation.operator)}")}"
@@ -35,9 +35,13 @@ module Saboteur
35
35
  # status keeps its meaning either way.
36
36
  attr_reader :confirmation
37
37
 
38
+ # Whether the mutated expression ever produced a value different from the
39
+ # original one while the covering tests ran; nil when not probed (#1290).
40
+ attr_reader :value_differed
41
+
38
42
  def initialize(subject:, mutation:, status:, mutated_source:, reason: nil, runtime: nil, killed_by: [],
39
43
  covered_by: [], kill_cause: nil, reused: false, rejected_kill: false, executed: nil,
40
- confirmation: nil)
44
+ confirmation: nil, value_differed: nil)
41
45
  raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
42
46
 
43
47
  @subject = subject
@@ -53,6 +57,7 @@ module Saboteur
53
57
  @rejected_kill = rejected_kill
54
58
  @reused = reused
55
59
  @confirmation = confirmation
60
+ @value_differed = value_differed
56
61
  @ignored = nil
57
62
  # triage_state/triage_reason stay unset until the ignore list writes
58
63
  # them: the accessors read as nil before that, and there is no
@@ -69,7 +74,7 @@ module Saboteur
69
74
  MutantResult.new(subject: subject, mutation: mutation, status: status, mutated_source: mutated_source,
70
75
  reason: reason, runtime: runtime, killed_by: killed_by, covered_by: covered_by,
71
76
  kill_cause: kill_cause, reused: reused?, rejected_kill: rejected_kill?,
72
- executed: executed, confirmation: confirmation)
77
+ executed: executed, confirmation: confirmation, value_differed: value_differed)
73
78
  end
74
79
 
75
80
  # The reason a --flaky-check disagreement starts with: unverified, but
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.12.2"
5
+ VERSION = "0.12.3"
6
6
  end
data/lib/saboteur.rb CHANGED
@@ -70,6 +70,7 @@ require_relative "saboteur/execution/marker_sweep"
70
70
  require_relative "saboteur/execution/supervisor"
71
71
  require_relative "saboteur/execution/reevaluator"
72
72
  require_relative "saboteur/execution/wrapper"
73
+ require_relative "saboteur/execution/site_probe"
73
74
  require_relative "saboteur/execution/runner"
74
75
  require_relative "saboteur/execution/bundle_gemfile_pin"
75
76
  require_relative "saboteur/execution/gemfile_path_sources"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saboteur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.2
4
+ version: 0.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
@@ -164,6 +164,7 @@ files:
164
164
  - lib/saboteur/execution/runner.rb
165
165
  - lib/saboteur/execution/sandbox.rb
166
166
  - lib/saboteur/execution/seatbelt.rb
167
+ - lib/saboteur/execution/site_probe.rb
167
168
  - lib/saboteur/execution/stale_captures.rb
168
169
  - lib/saboteur/execution/supervisor.rb
169
170
  - lib/saboteur/execution/supervisor_batch.rb