saboteur 0.9.0 → 0.10.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30c6af46486b9fc1138b568c45934cc895f786a79ea5215607f493a5a7d5af68
4
- data.tar.gz: a7a8038afc6166347fac807028cf7f45acecbb7e4c27e32f8917c4167b805bac
3
+ metadata.gz: e7d127c4a50466eb11e40c8f1f8ac92b013621d76ddfd3f122823ec85f0ff726
4
+ data.tar.gz: 8768ae6add39713a3ffb0222609c096ddb8e71e7edcb174f83e0ce3719c6296f
5
5
  SHA512:
6
- metadata.gz: 66817191adf235c1fb6adb41d3218704557d2d3305fab299f17cc38bb3b392733f0a52615d84aadb5e4fd940046db9336de9efa178b205d58cc8e1c61374c25a
7
- data.tar.gz: 132649f6b81d5ee312341a032df36b890af481704deee6c4b5cd4a013736c857ddc7887b27318409a1db05ea2621cbc8b2791ff4d0c40d3509810a6e13f7a405
6
+ metadata.gz: eb07cd559cf3b12dadd0e7b1a148f04e2e6aada3e961f9dfcd249f05da69cd7f59bc8a43335450df86ae4b6406be013ca7e737357ac3e07023169f6f9d434e52
7
+ data.tar.gz: 1cb27f6614903b63907f1ddf54872bdcfa1e1ab2c288bd40f4ed3ce9e247bed320105afb33fcb03e845a03887f7343250c36d2b82f35a1c7da61227dd83f0244
data/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.0] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - Survivor labels in the terminal: surviving mutants carry a label and
14
+ the findings collapse per statement, ignored mutants are reported with
15
+ their reasons, and external-runner runs emit the `tool_limit` label
16
+ (#1065, #1071).
17
+ - Pins covering the terminal collapse, the `--all-mutants` wiring, and
18
+ the phase1 end-to-end default (#1065).
19
+
20
+ ### Changed
21
+
22
+ - The terminal disambiguates the two no-coverage skip counts (#1087).
23
+ - `test_selection = :all` is gone: the 17 mirror-breaking files are
24
+ mapped explicitly instead (#1077).
25
+
26
+ ### Fixed
27
+
28
+ - The `element_removal` help text shows that any array literal works
29
+ (#1086).
30
+
9
31
  ## [0.9.0] - 2026-10-20
10
32
 
11
33
  ### Added
data/README.md CHANGED
@@ -89,7 +89,8 @@ killed 8 survived 2 timeout 0 resource limit 0 error 0 score 80.0 %
89
89
  Each survivor comes with its diff, the tests that ran, and a triage label
90
90
  (`likely_equivalent`, `arid`, `missing_test`) to sort what to read first.
91
91
  The labels are heuristics for reading order, never a diagnosis, and they
92
- never change the score.
92
+ never change the score. The terminal prints one finding per statement with
93
+ the label mix and mutant count; `--all-mutants` lists every mutant.
93
94
 
94
95
  A run on a tree with uncommitted changes opens with a warning that splits
95
96
  the count into changes inside the mutation scope, outside it, and
@@ -21,6 +21,8 @@ module Saboteur
21
21
  opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
22
22
  @color = v ? :always : :never
23
23
  end
24
+ opts.on("--all-mutants", "Text report: list every survived mutant;",
25
+ "default: one finding per statement, with the label mix") { set(:all_mutants, true) }
24
26
  end
25
27
  end
26
28
  end
@@ -13,7 +13,8 @@ module Saboteur
13
13
  def text_run(report, engine)
14
14
  @err.puts
15
15
  Reporter::Terminal.new(@out, verbose: @verbose || false, color: @color || :auto,
16
- replay_command: survivor_replay_command(report, engine)).call(report)
16
+ replay_command: survivor_replay_command(report, engine),
17
+ all_mutants: options[:all_mutants] || false).call(report)
17
18
  Reporter::Replay.new(@out).call(report) if options[:id]
18
19
  code = exit_code(report)
19
20
  # The JSON carries the exit cause an outer harness trusts, so it is
data/lib/saboteur/cli.rb CHANGED
@@ -131,7 +131,7 @@ module Saboteur
131
131
 
132
132
  def agent_requested? = self.class.agent_requested?(@argv)
133
133
 
134
- def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
134
+ def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run, :all_mutants)
135
135
 
136
136
  def add_subject(expr) = (options[:subjects] ||= []) << expr
137
137
 
@@ -393,11 +393,10 @@ module Saboteur
393
393
 
394
394
  def preloaded_message(files)
395
395
  names = Text.name_list(files.map { |file| file.delete_prefix("#{@root}/") })
396
- "#{names} loaded before saboteur started coverage (a require ran before the " \
397
- "executable's Coverage.start - commonly the gemspec requiring the library while bundler/setup " \
398
- "evaluates it, or RUBYOPT -r), so no test can ever cover #{files.size > 1 ? 'them' : 'it'}. " \
399
- "Let the test suite load these " \
400
- "files; a gemspec should require only its version file."
396
+ "#{names} can never be covered: loaded before saboteur started coverage\n " \
397
+ "a require ran before the executable's Coverage.start, commonly the gemspec requiring the " \
398
+ "library while bundler/setup evaluates it, or RUBYOPT -r\n " \
399
+ "Let the test suite load these files; a gemspec should require only its version file."
401
400
  end
402
401
 
403
402
  # Bootsnap is named only when it is loaded: blaming a library the app
@@ -4,6 +4,7 @@
4
4
  require "prism"
5
5
  require_relative "base"
6
6
  require_relative "filter_replacement"
7
+ require_relative "copy_replacement"
7
8
  require_relative "literal_replacement"
8
9
  require_relative "argument_removal"
9
10
  require_relative "argument_swap"
@@ -21,6 +22,7 @@ require_relative "interpolation_removal"
21
22
  require_relative "jump_value"
22
23
  require_relative "regex_replacement"
23
24
  require_relative "rescue_removal"
25
+ require_relative "ensure_removal"
24
26
  require_relative "when_removal"
25
27
  require_relative "unary_removal"
26
28
 
@@ -76,10 +78,10 @@ module Saboteur
76
78
  SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
77
79
  # The single-purpose sources, in the order they run after the shared walk.
78
80
  DELEGATED = [
79
- FilterReplacement, LiteralReplacement, ArgumentRemoval, PairRemoval, ElementRemoval, ChainRemoval,
80
- EndpointReplacement, RangeReplacement, OperatorAssignment, ConditionNegation, MethodSwap,
81
+ FilterReplacement, CopyReplacement, LiteralReplacement, ArgumentRemoval, PairRemoval, ElementRemoval,
82
+ ChainRemoval, EndpointReplacement, RangeReplacement, OperatorAssignment, ConditionNegation, MethodSwap,
81
83
  OperandReplacement, InterpolationRemoval, RegexReplacement, ArgumentSwap, JumpValue, RescueRemoval,
82
- WhenRemoval, UnaryRemoval
84
+ EnsureRemoval, WhenRemoval, UnaryRemoval
83
85
  ].freeze
84
86
 
85
87
  def initialize(arid: DEFAULT_ARID, operators: [], exclude_operators: [])
@@ -0,0 +1,72 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "base"
6
+
7
+ module Saboteur
8
+ module MutationSource
9
+ # Drops a defensive copy (x.dup, x.clone becomes x) or makes a deep copy
10
+ # shallow (x.deep_dup becomes x.dup). Both let a caller and a callee share
11
+ # state that the original code kept apart.
12
+ class CopyReplacement < Base
13
+ DROPPED = %i[dup clone].freeze
14
+ SHALLOWER = { deep_dup: "dup" }.freeze
15
+ SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
16
+
17
+ # Base inherits Object's empty initializer; there is no parent state to set.
18
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
19
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
20
+ end
21
+
22
+ def name
23
+ "copy_replacement"
24
+ end
25
+
26
+ def mutations_for(subject)
27
+ found = []
28
+ visit(subject.body_node, subject, found)
29
+ found
30
+ end
31
+
32
+ private
33
+
34
+ def visit(node, subject, found)
35
+ return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
36
+
37
+ if node.is_a?(Prism::StatementsNode)
38
+ node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
39
+ return
40
+ end
41
+
42
+ replace_copy(node, subject, found) if node.is_a?(Prism::CallNode)
43
+ node.compact_child_nodes.each { |child| visit(child, subject, found) }
44
+ nil
45
+ end
46
+
47
+ def replace_copy(node, subject, found)
48
+ return unless node.receiver && node.block.nil? && node.arguments.nil?
49
+
50
+ edit = DROPPED.include?(node.name) ? drop(node, subject) : shallow(node, subject)
51
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name) if edit
52
+ end
53
+
54
+ # Receiver stays; the dot (or &.) through the end of the call goes.
55
+ def drop(node, subject)
56
+ Edit.new(file: subject.file, start_byte: node.call_operator_loc.start_offset,
57
+ end_byte: node.end_offset, replacement: "")
58
+ end
59
+
60
+ def shallow(node, subject)
61
+ replacement = SHALLOWER[node.name] or return
62
+ location = node.message_loc
63
+ Edit.new(file: subject.file, start_byte: location.start_offset, end_byte: location.end_offset,
64
+ replacement: replacement)
65
+ end
66
+
67
+ def arid?(node)
68
+ @arid.any? { |pattern| pattern.match?(node.slice) }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,49 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "base"
6
+ require_relative "operator_visit"
7
+
8
+ module Saboteur
9
+ module MutationSource
10
+ # Deletes the `ensure` clause of a begin block, method body or do block, so
11
+ # the cleanup it runs is skipped: `begin; x; ensure; y; end` becomes
12
+ # `begin; x; end`. A test that never checks the cleanup cannot tell the
13
+ # clause from no clause. Removing the clause's statements is
14
+ # statement_removal's job; this removes the clause itself. A rescue or
15
+ # else clause beside it stays.
16
+ class EnsureRemoval < Base
17
+ include OperatorVisit
18
+
19
+ # Base inherits Object's empty initializer; there is no parent state to set.
20
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
21
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
22
+ end
23
+
24
+ def name
25
+ "ensure_removal"
26
+ end
27
+
28
+ private
29
+
30
+ def mutate_node(node, subject, found)
31
+ return unless node.is_a?(Prism::BeginNode)
32
+
33
+ clause = node.ensure_clause
34
+ return if clause.nil? || heredoc?(clause)
35
+
36
+ # The clause's location runs through the closing `end`, which stays.
37
+ edit = Edit.new(file: subject.file, start_byte: clause.start_offset,
38
+ end_byte: clause.end_keyword_loc.start_offset, replacement: "")
39
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
40
+ end
41
+
42
+ # A heredoc's body lives on later lines: cutting the clause that opens
43
+ # one would leave the body behind.
44
+ def heredoc?(node)
45
+ node.slice.start_with?("<<") || node.compact_child_nodes.any? { |child| heredoc?(child) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -20,6 +20,7 @@ module Saboteur
20
20
  "and_assign" => "x &&= y -> x = y",
21
21
  "safe_navigation" => "x&.name -> x.name",
22
22
  "statement_removal" => "check; value -> nil; value",
23
+ "copy_replacement" => "x.dup -> x",
23
24
  "filter_replacement" => "xs.select { |x| x } -> xs.reject { |x| x }",
24
25
  "literal_string" => "\"hello\" -> \"\"",
25
26
  "literal_boolean" => "true -> false",
@@ -37,6 +38,7 @@ module Saboteur
37
38
  "interpolation_removal" => "\"hi \#{name}\" -> \"hi \"",
38
39
  "jump_value" => "return total -> return nil",
39
40
  "rescue_removal" => "begin; x; rescue; y; end -> begin; x; end",
41
+ "ensure_removal" => "begin; x; ensure; y; end -> begin; x; end",
40
42
  "when_removal" => "case x; when 1 then a; when 2 then b; end -> case x; when 2 then b; end",
41
43
  "unary_removal" => "-x -> x",
42
44
  "regex_replacement" => "/\\A\\d+\\z/ -> /\\d+\\z/"
@@ -28,12 +28,13 @@ module Saboteur
28
28
  "safe_navigation" => "Replace safe navigation with a regular call",
29
29
  "statement_removal" => "Replace a statement with nil",
30
30
  "filter_replacement" => "Swap select and reject",
31
+ "copy_replacement" => "Drop a dup or clone, or make deep_dup shallow",
31
32
  "literal_string" => "Empty a string literal",
32
33
  "literal_boolean" => "Swap a boolean literal",
33
34
  "argument_swap" => "Swap two positional call arguments",
34
35
  "argument_removal" => "Remove a call argument",
35
36
  "pair_removal" => "Remove a hash pair",
36
- "element_removal" => "Remove an element of a constant array literal",
37
+ "element_removal" => "Remove an element of an array literal",
37
38
  "endpoint_replacement" => "Swap explicit no-argument first and last",
38
39
  "chain_removal" => "Remove one call from a method chain",
39
40
  "method_swap" => "Swap a method with its semantic opposite",
@@ -41,6 +42,7 @@ module Saboteur
41
42
  "interpolation_removal" => "Remove part of an interpolated string",
42
43
  "jump_value" => "Replace the value of return, next or break with nil",
43
44
  "rescue_removal" => "Remove an exception handler so the error propagates",
45
+ "ensure_removal" => "Remove an ensure clause so its cleanup is skipped",
44
46
  "when_removal" => "Remove one when branch of a case",
45
47
  "unary_removal" => "Drop a unary minus or bitwise complement",
46
48
  "regex_replacement" => "Drop a regex anchor or swap a regex quantifier"
@@ -26,9 +26,10 @@ module Saboteur
26
26
  # covering list is only the PLANNED coverage: whole-body (coarse)
27
27
  # mutants carry method-level guesses, so they never get this
28
28
  # label, and neither does anything without real coverage.
29
- # tool_limit - reserved for survivors saboteur could not observe
30
- # faithfully (re-eval refusal, external runner without coverage). No
31
- # result records that signal yet, so the label is never emitted.
29
+ # tool_limit - survivors saboteur could not observe faithfully: an
30
+ # external-runner run records only the pseudo-test id, never real
31
+ # coverage (#1071). A re-eval refusal records no signal on the
32
+ # result yet, so that half of the reservation is still unemitted.
32
33
  #
33
34
  # A survivor with no matching signal stays unlabeled.
34
35
  module SurvivorClassification
@@ -39,6 +40,7 @@ module Saboteur
39
40
  return nil unless result.status == :survived
40
41
  return "likely_equivalent" if equivalent?(result)
41
42
  return "arid" if arid?(result, arid)
43
+ return "tool_limit" if tool_limited?(result)
42
44
  return "missing_test" if possible_test_gap?(result)
43
45
 
44
46
  nil
@@ -62,6 +64,13 @@ module Saboteur
62
64
  frozen_string_literal: subject.frozen_literals)
63
65
  end
64
66
 
67
+ # An external runner reports one pseudo id for every mutant and no
68
+ # coverage map, so a survivor there was never observed against real
69
+ # tests. Pseudo id next to a real id keeps missing_test (#305).
70
+ def tool_limited?(result)
71
+ result.covered_by.any? && result.covered_by.all? { |id| id == Execution::ExternalRunner::PSEUDO_TEST }
72
+ end
73
+
65
74
  # covered_by is a PLANNED covering list, not a verdict about what
66
75
  # the suite asserts: coarse (whole-body) mutants carry method-level
67
76
  # guesses, and a --test-command run records only the pseudo-test id
@@ -26,8 +26,10 @@ module Saboteur
26
26
  "endpoint_replacement" => "try a collection with 2 or more elements, to tell first from last",
27
27
  "jump_value" => "try asserting the value this early exit returns",
28
28
  "rescue_removal" => "try an example where the guarded code raises, and assert what the handler returns",
29
+ "ensure_removal" => "try asserting the cleanup effect of the ensure clause",
29
30
  "when_removal" => "try a value that only this when branch matches, and assert its result",
30
31
  "unary_removal" => "try a value where the sign or complement changes the result",
32
+ "copy_replacement" => "try changing the result after the call and check the original is untouched",
31
33
  "filter_replacement" => "try items that match and items that do not, to tell select from reject"
32
34
  }.freeze
33
35
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  require_relative "mutation_text"
5
5
  require_relative "survivor_hints"
6
+ require_relative "survivor_classification"
6
7
 
7
8
  module Saboteur
8
9
  module Reporter
@@ -12,9 +13,11 @@ module Saboteur
12
13
  class SurvivorList
13
14
  MAX_PASSING_TESTS = 5
14
15
 
15
- def initialize(out, palette)
16
+ def initialize(out, palette, all_mutants: false, classifier: SurvivorClassification)
16
17
  @out = out
17
18
  @palette = palette
19
+ @all_mutants = all_mutants
20
+ @classifier = classifier
18
21
  end
19
22
 
20
23
  # Every survivor, in report order; the grouping keeps them readable.
@@ -43,10 +46,33 @@ module Saboteur
43
46
  first = on_line.first
44
47
  @out.puts " #{path}:#{line} in #{first.subject.id}"
45
48
  @out.puts " #{@palette.paint(:dim, "#{line}:")} #{source_line(first, line)}"
46
- evidence_groups(report, on_line).each { |group| evidence_group(report, path, group) }
49
+ if @all_mutants
50
+ evidence_groups(report, on_line).each { |group| evidence_group(report, path, group) }
51
+ else
52
+ live, ignored = on_line.partition { |result| result.ignored.nil? }
53
+ statement_finding(live) unless live.empty?
54
+ ignored.each { |result| mutation(result) }
55
+ end
47
56
  end
48
57
  end
49
58
 
59
+ # Default view (#1065): one finding per statement - how many mutants
60
+ # survive here and the mix of their classification labels, with the
61
+ # covering tests once. --all-mutants restores the per-mutant list.
62
+ def statement_finding(on_line)
63
+ count = "#{on_line.size} #{on_line.one? ? 'mutant' : 'mutants'}"
64
+ mix = on_line.group_by { |result| @classifier.label(result) || "unlabeled" }
65
+ .sort_by { |label, _| label }
66
+ .map { |label, group| "#{group.size} #{label}" }
67
+ .join(", ")
68
+ @out.puts " #{@palette.paint(:red, '-')} #{count}: #{mix}"
69
+ tests_line(on_line.flat_map(&:covered_by).uniq, on_line.first.reused?)
70
+ digests = on_line.map(&:digest)
71
+ more = digests.size - 3
72
+ suffix = " (+#{more} more in the JSON report)" if more.positive?
73
+ @out.puts " #{digests.first(3).join(' ')}#{suffix}"
74
+ end
75
+
50
76
  # Mutants on one line share the printed evidence when it names the
51
77
  # same tests: the covering-test list is compared as a set (#502), so
52
78
  # one line's mutants no longer reprint an identical block only because
@@ -79,6 +105,8 @@ module Saboteur
79
105
  end
80
106
  text = " #{@palette.paint(:red, '-')} #{result.mutation.operator} " \
81
107
  "#{change} #{result.digest}"
108
+ label = @classifier.label(result)
109
+ text += @palette.paint(:cyan, " [#{label}]") if label
82
110
  text += " (ignored: #{result.ignored})" if result.ignored
83
111
  @out.puts text
84
112
  hint(result)
@@ -100,20 +128,20 @@ module Saboteur
100
128
  # killers first, the order the run used. Line coverage can overstate which
101
129
  # tests exercise the line, so the list is a lead, not proof.
102
130
  def passing_tests(result)
103
- tests = result.covered_by
131
+ tests_line(result.covered_by, result.reused?)
132
+ end
133
+
134
+ def tests_line(tests, reused)
104
135
  return if tests.empty?
105
136
 
106
137
  @out.puts " #{tests.size} #{tests.one? ? 'test' : 'tests'} covered this line and " \
107
- "#{passed_phrase(result)} (the missing check may belong in one of them):"
138
+ "#{reused ? 'passed in an earlier run (--incremental)' : 'still passed'} " \
139
+ "(the missing check may belong in one of them):"
108
140
  tests.first(MAX_PASSING_TESTS).each { |name| @out.puts " #{name}" }
109
141
  more = tests.size - MAX_PASSING_TESTS
110
142
  @out.puts " ... #{more} more in the JSON report (coveredBy)" if more.positive?
111
143
  end
112
144
 
113
- def passed_phrase(result)
114
- result.reused? ? "passed in an earlier run (--incremental)" : "still passed"
115
- end
116
-
117
145
  # In diff mode: the changed lines this mutant touches.
118
146
  def changed_lines(report, path, result)
119
147
  lines = report.changed_lines_for(result)
@@ -18,8 +18,9 @@ module Saboteur
18
18
  MAX_ERRORS = 10
19
19
  MAX_PASSING_TESTS = SurvivorList::MAX_PASSING_TESTS
20
20
 
21
- def initialize(out, verbose: false, color: :auto, replay_command: nil)
21
+ def initialize(out, verbose: false, color: :auto, replay_command: nil, all_mutants: false)
22
22
  @out = SafeOut.new(out)
23
+ @all_mutants = all_mutants
23
24
  @verbose = verbose
24
25
  @palette = Palette.for(out, mode: color)
25
26
  @score_table = ScoreTable.new(@out, @palette)
@@ -149,7 +150,7 @@ module Saboteur
149
150
  'errors, resource limits and skips excluded'
150
151
  end}"
151
152
  end
152
- report.skip_reasons.each { |reason, count| @out.puts "skipped: #{reason}: #{count}" }
153
+ report.skip_reasons.each { |reason, count| @out.puts skip_reason_line(reason, count) }
153
154
  reused = report.results.count(&:reused?)
154
155
  @out.puts "reused #{reused} of #{report.results.size} verdicts from earlier runs (--incremental)" if
155
156
  reused.positive?
@@ -237,10 +238,19 @@ module Saboteur
237
238
  end
238
239
  end
239
240
 
241
+ # "no coverage" names two different things (#1070): subjects no test
242
+ # runs at all, and mutant locations inside covered methods that no
243
+ # test reaches. Say which is which; every other reason prints as-is.
244
+ def skip_reason_line(reason, count)
245
+ reason == "no coverage" ? "mutant locations not reached: #{count}" : "skipped: #{reason}: #{count}"
246
+ end
247
+
248
+ def skipped_subject_line(reason, count)
249
+ reason == "no coverage" ? "methods never run by tests: #{count}" : "skipped subjects: #{reason}: #{count}"
250
+ end
251
+
240
252
  def skipped_subjects(report)
241
- report.skipped_subjects.values.tally.each do |reason, count|
242
- @out.puts "skipped subjects: #{reason}: #{count}"
243
- end
253
+ report.skipped_subjects.values.tally.each { |reason, count| @out.puts skipped_subject_line(reason, count) }
244
254
  report.skipped_subjects.each do |subject, reason|
245
255
  @out.puts " #{subject.id} #{relative(subject.file, report.root)}:#{subject.start_line}: #{reason}"
246
256
  end
@@ -350,7 +360,7 @@ module Saboteur
350
360
  end
351
361
 
352
362
  def survivors(report)
353
- SurvivorList.new(@out, @palette).call(report, ranked_survivors(report))
363
+ SurvivorList.new(@out, @palette, all_mutants: @all_mutants).call(report, ranked_survivors(report))
354
364
  @out.puts unless report.survivors.empty?
355
365
  end
356
366
 
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.9.0"
5
+ VERSION = "0.10.0"
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saboteur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-20 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -183,9 +183,11 @@ files:
183
183
  - lib/saboteur/mutation_source/chain_removal.rb
184
184
  - lib/saboteur/mutation_source/condition_negation.rb
185
185
  - lib/saboteur/mutation_source/constant_literals.rb
186
+ - lib/saboteur/mutation_source/copy_replacement.rb
186
187
  - lib/saboteur/mutation_source/edit.rb
187
188
  - lib/saboteur/mutation_source/element_removal.rb
188
189
  - lib/saboteur/mutation_source/endpoint_replacement.rb
190
+ - lib/saboteur/mutation_source/ensure_removal.rb
189
191
  - lib/saboteur/mutation_source/entry_edits.rb
190
192
  - lib/saboteur/mutation_source/filter_replacement.rb
191
193
  - lib/saboteur/mutation_source/hazard_guard.rb