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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +2 -1
- data/lib/saboteur/cli/output_options.rb +2 -0
- data/lib/saboteur/cli/run_reporting.rb +2 -1
- data/lib/saboteur/cli.rb +1 -1
- data/lib/saboteur/engine/coverage_store.rb +4 -5
- data/lib/saboteur/mutation_source/builtin.rb +5 -3
- data/lib/saboteur/mutation_source/copy_replacement.rb +72 -0
- data/lib/saboteur/mutation_source/ensure_removal.rb +49 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +2 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +3 -1
- data/lib/saboteur/reporter/survivor_classification.rb +12 -3
- data/lib/saboteur/reporter/survivor_hints.rb +2 -0
- data/lib/saboteur/reporter/survivor_list.rb +36 -8
- data/lib/saboteur/reporter/terminal.rb +16 -6
- data/lib/saboteur/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7d127c4a50466eb11e40c8f1f8ac92b013621d76ddfd3f122823ec85f0ff726
|
|
4
|
+
data.tar.gz: 8768ae6add39713a3ffb0222609c096ddb8e71e7edcb174f83e0ce3719c6296f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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
|
|
397
|
-
"executable's Coverage.start
|
|
398
|
-
"evaluates it, or RUBYOPT -r
|
|
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,
|
|
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
|
|
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 -
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"#{
|
|
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
|
|
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
|
|
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
|
|
data/lib/saboteur/version.rb
CHANGED
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.
|
|
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-
|
|
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
|