mutineer 0.6.2 → 0.8.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 +59 -0
- data/README.md +63 -2
- data/lib/mutineer/baseline.rb +86 -0
- data/lib/mutineer/changed_lines.rb +29 -12
- data/lib/mutineer/cli.rb +137 -9
- data/lib/mutineer/config.rb +30 -2
- data/lib/mutineer/coverage_map.rb +135 -11
- data/lib/mutineer/isolation.rb +89 -40
- data/lib/mutineer/minitest_integration.rb +13 -9
- data/lib/mutineer/mutant_id.rb +66 -0
- data/lib/mutineer/mutation.rb +12 -6
- data/lib/mutineer/mutator_registry.rb +21 -3
- data/lib/mutineer/mutators/arithmetic.rb +8 -2
- data/lib/mutineer/mutators/base.rb +11 -3
- data/lib/mutineer/mutators/boolean_connector.rb +15 -5
- data/lib/mutineer/mutators/boolean_literal.rb +19 -6
- data/lib/mutineer/mutators/comparison.rb +7 -8
- data/lib/mutineer/mutators/condition_negation.rb +14 -5
- data/lib/mutineer/mutators/literal_mutation.rb +15 -4
- data/lib/mutineer/mutators/return_nil.rb +22 -7
- data/lib/mutineer/mutators/statement_removal.rb +6 -9
- data/lib/mutineer/pairing.rb +86 -0
- data/lib/mutineer/parser.rb +17 -7
- data/lib/mutineer/project.rb +73 -2
- data/lib/mutineer/reporter.rb +172 -8
- data/lib/mutineer/result.rb +128 -32
- data/lib/mutineer/runner.rb +101 -9
- data/lib/mutineer/subject.rb +10 -6
- data/lib/mutineer/test_runners/minitest.rb +5 -4
- data/lib/mutineer/test_runners/rspec.rb +10 -17
- data/lib/mutineer/test_runners.rb +9 -2
- data/lib/mutineer/version.rb +2 -1
- data/lib/mutineer/worker_pool.rb +34 -21
- data/lib/mutineer.rb +3 -0
- metadata +18 -1
data/lib/mutineer/project.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require "set"
|
|
4
5
|
require_relative "parser"
|
|
5
6
|
require_relative "subject"
|
|
6
7
|
|
|
@@ -8,12 +9,17 @@ module Mutineer
|
|
|
8
9
|
# Subject discovery: parse each path and walk its AST for method definitions,
|
|
9
10
|
# tracking the enclosing class/module namespace.
|
|
10
11
|
class Project
|
|
11
|
-
#
|
|
12
|
+
# Discovers subjects from source paths.
|
|
13
|
+
#
|
|
14
|
+
# @param paths [Array<String>] source file paths.
|
|
15
|
+
# @param only [String, nil] optional qualified-name filter.
|
|
16
|
+
# @return [Array<Mutineer::Subject>] discovered subjects.
|
|
12
17
|
def self.discover(paths, only: nil)
|
|
13
18
|
subjects = Array(paths).flat_map do |path|
|
|
14
19
|
result = Parser.parse_file(path)
|
|
15
20
|
visitor = SubjectVisitor.new(path)
|
|
16
21
|
visitor.visit(result.value)
|
|
22
|
+
visitor.promote_module_functions!
|
|
17
23
|
visitor.subjects
|
|
18
24
|
end
|
|
19
25
|
only ? subjects.select { |s| s.qualified_name == only } : subjects
|
|
@@ -24,31 +30,87 @@ module Mutineer
|
|
|
24
30
|
class SubjectVisitor < Prism::Visitor
|
|
25
31
|
attr_reader :subjects
|
|
26
32
|
|
|
33
|
+
# Builds a subject visitor.
|
|
34
|
+
#
|
|
35
|
+
# @param file [String] source file path being visited.
|
|
27
36
|
def initialize(file)
|
|
28
37
|
@file = file
|
|
29
38
|
@namespace_stack = []
|
|
30
39
|
@subjects = []
|
|
31
40
|
@singleton_depth = 0
|
|
41
|
+
@module_function_active = false # bareword `module_function` seen in this module body
|
|
42
|
+
@module_function_names = [] # names from `module_function :a, :b` / `module_function def`
|
|
32
43
|
super()
|
|
33
44
|
end
|
|
34
45
|
|
|
46
|
+
# Promote `module_function :name` / `module_function def name` subjects to
|
|
47
|
+
# singleton after the full walk — the naming call may appear before or after
|
|
48
|
+
# the def, so it can't be decided at visit_def_node time (#20).
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
def promote_module_functions!
|
|
52
|
+
return if @module_function_names.empty?
|
|
53
|
+
|
|
54
|
+
names = @module_function_names.to_set
|
|
55
|
+
@subjects.each { |s| s.singleton = true if names.include?(s.name) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Visits class nodes and tracks namespace nesting.
|
|
59
|
+
#
|
|
60
|
+
# @param node [Prism::ClassNode] class node.
|
|
61
|
+
# @return [void]
|
|
35
62
|
def visit_class_node(node)
|
|
36
63
|
@namespace_stack.push(extract_constant_name(node.constant_path))
|
|
64
|
+
saved = @module_function_active
|
|
65
|
+
@module_function_active = false # module_function state does not cross a class boundary
|
|
37
66
|
super
|
|
67
|
+
@module_function_active = saved
|
|
38
68
|
@namespace_stack.pop
|
|
39
69
|
end
|
|
40
70
|
|
|
71
|
+
# Visits module nodes and tracks namespace nesting.
|
|
72
|
+
#
|
|
73
|
+
# @param node [Prism::ModuleNode] module node.
|
|
74
|
+
# @return [void]
|
|
41
75
|
def visit_module_node(node)
|
|
42
76
|
@namespace_stack.push(extract_constant_name(node.constant_path))
|
|
77
|
+
saved = @module_function_active
|
|
78
|
+
@module_function_active = false # each module body starts without module_function active
|
|
43
79
|
super
|
|
80
|
+
@module_function_active = saved
|
|
44
81
|
@namespace_stack.pop
|
|
45
82
|
end
|
|
46
83
|
|
|
84
|
+
# Track `module_function` so its methods are recorded as singletons (#20) —
|
|
85
|
+
# the called form is the singleton method on the module object. Bareword
|
|
86
|
+
# `module_function` flips all SUBSEQUENT defs in this body; the argument
|
|
87
|
+
# forms (`:sym`, `def`) name methods promoted after the walk.
|
|
88
|
+
#
|
|
89
|
+
# @param node [Prism::CallNode] call node.
|
|
90
|
+
# @return [void]
|
|
91
|
+
def visit_call_node(node)
|
|
92
|
+
if node.name == :module_function && node.receiver.nil?
|
|
93
|
+
args = node.arguments&.arguments || []
|
|
94
|
+
if args.empty?
|
|
95
|
+
@module_function_active = true
|
|
96
|
+
else
|
|
97
|
+
args.each do |arg|
|
|
98
|
+
@module_function_names << arg.value.to_sym if arg.is_a?(Prism::SymbolNode)
|
|
99
|
+
@module_function_names << arg.name if arg.is_a?(Prism::DefNode)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
super
|
|
104
|
+
end
|
|
105
|
+
|
|
47
106
|
# Methods inside `class << self` are class methods of the enclosing
|
|
48
107
|
# namespace, but their def nodes have no receiver — track the singleton
|
|
49
108
|
# context so they're recorded as singleton (so redefine targets the
|
|
50
109
|
# singleton_class, not instances). `class << some_other_obj` can't be
|
|
51
110
|
# represented against the namespace, so its defs are skipped (not recursed).
|
|
111
|
+
#
|
|
112
|
+
# @param node [Prism::SingletonClassNode] singleton-class node.
|
|
113
|
+
# @return [void]
|
|
52
114
|
def visit_singleton_class_node(node)
|
|
53
115
|
return unless node.expression.is_a?(Prism::SelfNode)
|
|
54
116
|
|
|
@@ -57,12 +119,16 @@ module Mutineer
|
|
|
57
119
|
@singleton_depth -= 1
|
|
58
120
|
end
|
|
59
121
|
|
|
122
|
+
# Records a discovered method definition.
|
|
123
|
+
#
|
|
124
|
+
# @param node [Prism::DefNode] method definition node.
|
|
125
|
+
# @return [void]
|
|
60
126
|
def visit_def_node(node)
|
|
61
127
|
@subjects << Subject.new(
|
|
62
128
|
file: @file,
|
|
63
129
|
namespace: @namespace_stack.dup,
|
|
64
130
|
name: node.name,
|
|
65
|
-
singleton: !node.receiver.nil? || @singleton_depth.positive
|
|
131
|
+
singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
|
|
66
132
|
def_node: node
|
|
67
133
|
)
|
|
68
134
|
super
|
|
@@ -70,6 +136,11 @@ module Mutineer
|
|
|
70
136
|
|
|
71
137
|
private
|
|
72
138
|
|
|
139
|
+
# Extracts a constant name from a Prism constant node.
|
|
140
|
+
#
|
|
141
|
+
# @api private
|
|
142
|
+
# @param node [Prism::Node] constant node.
|
|
143
|
+
# @return [String, nil] constant name.
|
|
73
144
|
def extract_constant_name(node)
|
|
74
145
|
case node
|
|
75
146
|
when Prism::ConstantReadNode
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -21,13 +21,14 @@ module Mutineer
|
|
|
21
21
|
# Single entry point (R20/R21). Branches on `format` ("human" | "json") and
|
|
22
22
|
# routes the rendered report to `output` (a file, with a stderr confirmation)
|
|
23
23
|
# or to `out`. Diagnostics always go to `err`.
|
|
24
|
-
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil)
|
|
24
|
+
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil, baseline: nil)
|
|
25
25
|
rendered =
|
|
26
26
|
if format == "json"
|
|
27
|
-
json_report
|
|
27
|
+
json_report(baseline)
|
|
28
28
|
else
|
|
29
29
|
sio = StringIO.new
|
|
30
30
|
human_report(sio, err, threshold)
|
|
31
|
+
baseline_section(sio, baseline) if baseline
|
|
31
32
|
sio.string
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -40,6 +41,12 @@ module Mutineer
|
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
|
|
44
|
+
# Renders the human report.
|
|
45
|
+
#
|
|
46
|
+
# @param out [IO] output stream.
|
|
47
|
+
# @param err [IO] error stream.
|
|
48
|
+
# @param threshold [Float] score threshold.
|
|
49
|
+
# @return [void]
|
|
43
50
|
def human_report(out, err, threshold)
|
|
44
51
|
if @agg.total.zero?
|
|
45
52
|
err.puts "No mutations generated — verify target files contain in-scope " \
|
|
@@ -53,6 +60,7 @@ module Mutineer
|
|
|
53
60
|
summary(out)
|
|
54
61
|
out.puts
|
|
55
62
|
score_line(out, err)
|
|
63
|
+
per_source(out)
|
|
56
64
|
|
|
57
65
|
survivors(out)
|
|
58
66
|
verdict(out, threshold) if threshold && threshold.positive?
|
|
@@ -73,7 +81,12 @@ module Mutineer
|
|
|
73
81
|
# Canonical machine-readable schema (KTD7). survivors/no_coverage are sorted
|
|
74
82
|
# by (file, line, operator) so output is byte-stable regardless of --jobs
|
|
75
83
|
# worker finish order (R22).
|
|
76
|
-
|
|
84
|
+
# Renders the JSON report.
|
|
85
|
+
#
|
|
86
|
+
# @api private
|
|
87
|
+
# @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
|
|
88
|
+
# @return [String] JSON text.
|
|
89
|
+
def json_report(baseline = nil)
|
|
77
90
|
killed = @agg.killed_count
|
|
78
91
|
survived = @agg.survived_count
|
|
79
92
|
# C8: null (not 0.0) on an empty denominator, matching the nil-vs-0.0
|
|
@@ -82,27 +95,81 @@ module Mutineer
|
|
|
82
95
|
score = @agg.mutation_score
|
|
83
96
|
|
|
84
97
|
doc = {
|
|
85
|
-
schema_version: "1.
|
|
98
|
+
schema_version: "1.1",
|
|
86
99
|
summary: {
|
|
87
100
|
total: @agg.total, killed: killed, survived: survived,
|
|
88
101
|
no_coverage: @agg.no_coverage_count,
|
|
102
|
+
uncapturable: @agg.uncapturable_count,
|
|
89
103
|
skipped_invalid: @agg.skipped_invalid_count,
|
|
90
104
|
errored: @agg.errored_count, timeout: @agg.timeout_count,
|
|
105
|
+
ignored: @agg.ignored_count,
|
|
91
106
|
score: score
|
|
92
107
|
},
|
|
93
108
|
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
94
109
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
95
110
|
no_coverage: @agg.results.select(&:no_coverage?).map { |r| no_coverage_json(r) }
|
|
96
|
-
.sort_by { |h| [h[:file], h[:line]] }
|
|
111
|
+
.sort_by { |h| [h[:file], h[:line]] },
|
|
112
|
+
# #9: same shape as no_coverage; additive key.
|
|
113
|
+
uncapturable: @agg.results.select(&:uncapturable?).map { |r| no_coverage_json(r) }
|
|
114
|
+
.sort_by { |h| [h[:file], h[:line]] },
|
|
115
|
+
# #10: equivalent mutants the user suppressed — emitted with their stable
|
|
116
|
+
# id so the user can audit what is silenced (and copy ids for survivors
|
|
117
|
+
# they want to add). Excluded from the score; never in `survivors`.
|
|
118
|
+
ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
|
|
119
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
120
|
+
# #11: per-source breakdown (additive; #13 consumes it). Sorted by file so
|
|
121
|
+
# output is byte-stable. Reuses AggregateResult via by_source.
|
|
122
|
+
per_source: @agg.by_source.map { |file, agg| per_source_json(file, agg) }
|
|
123
|
+
.sort_by { |h| h[:file] }
|
|
97
124
|
}
|
|
125
|
+
# #13: additive baseline-delta block, present only with --baseline. Existing
|
|
126
|
+
# consumers ignore the extra key; schema_version stays 1.1.
|
|
127
|
+
doc[:baseline] = baseline_json(baseline) if baseline
|
|
98
128
|
"#{JSON.generate(doc)}\n"
|
|
99
129
|
end
|
|
100
130
|
|
|
131
|
+
# #13: the same delta facts the human report prints, for dashboards. new_survivors
|
|
132
|
+
# reuse the ignored_json shape (subject/file/line/operator/token/id) and sort
|
|
133
|
+
# byte-stably so output doesn't depend on --jobs finish order.
|
|
134
|
+
def baseline_json(delta)
|
|
135
|
+
{
|
|
136
|
+
regressed: delta.regressed,
|
|
137
|
+
score_before: delta.score_before,
|
|
138
|
+
score_after: delta.score_after,
|
|
139
|
+
score_dropped: delta.score_drop,
|
|
140
|
+
new_survivors: delta.new_survivors.map { |r| ignored_json(r) }
|
|
141
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
142
|
+
fixed_survivors: delta.fixed_survivors.map do |h|
|
|
143
|
+
{ subject: h["subject"], file: h["file"], line: h["line"],
|
|
144
|
+
operator: h["operator"], id: h["id"] }
|
|
145
|
+
end.sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:operator].to_s] }
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Builds per-source JSON.
|
|
150
|
+
#
|
|
151
|
+
# @api private
|
|
152
|
+
# @param file [String] source file path.
|
|
153
|
+
# @param agg [Mutineer::AggregateResult] source aggregate.
|
|
154
|
+
# @return [Hash] per-source JSON object.
|
|
155
|
+
def per_source_json(file, agg)
|
|
156
|
+
{
|
|
157
|
+
file: file, total: agg.total,
|
|
158
|
+
killed: agg.killed_count, survived: agg.survived_count,
|
|
159
|
+
no_coverage: agg.no_coverage_count, score: agg.mutation_score
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Builds survivor JSON.
|
|
164
|
+
#
|
|
165
|
+
# @api private
|
|
166
|
+
# @param result [Mutineer::Result] survivor result.
|
|
167
|
+
# @return [Hash] survivor JSON object.
|
|
101
168
|
def survivor_json(result)
|
|
102
169
|
m = result.mutation
|
|
103
170
|
file = result.subject.file
|
|
104
171
|
source = @source_map[file] || File.read(file)
|
|
105
|
-
start_line, original_block, mutated_block, = diff_for(m, source)
|
|
172
|
+
start_line, original_block, mutated_block, token = diff_for(m, source)
|
|
106
173
|
minus = original_block.each_line.map { |l| "-#{l.chomp}" }.join("\n")
|
|
107
174
|
plus = mutated_block.each_line.map { |l| "+#{l.chomp}" }.join("\n")
|
|
108
175
|
{
|
|
@@ -110,10 +177,30 @@ module Mutineer
|
|
|
110
177
|
file: file,
|
|
111
178
|
line: start_line,
|
|
112
179
|
operator: m.operator.to_s,
|
|
180
|
+
# #10: the stable, copy-pasteable id (next to the human-readable token) so a
|
|
181
|
+
# user can paste it straight into .mutineer.yml `ignore:`.
|
|
182
|
+
id: result.id,
|
|
183
|
+
token: token,
|
|
113
184
|
diff: "--- a/#{file}\n+++ b/#{file}\n@@ -#{start_line} +#{start_line} @@\n#{minus}\n#{plus}\n"
|
|
114
185
|
}
|
|
115
186
|
end
|
|
116
187
|
|
|
188
|
+
# An entry under the JSON `ignored:` key — what the user already suppressed.
|
|
189
|
+
def ignored_json(result)
|
|
190
|
+
m = result.mutation
|
|
191
|
+
file = result.subject.file
|
|
192
|
+
source = @source_map[file] || File.read(file)
|
|
193
|
+
start_line, _orig, _mut, token = diff_for(m, source)
|
|
194
|
+
{
|
|
195
|
+
subject: result.subject.qualified_name,
|
|
196
|
+
file: file,
|
|
197
|
+
line: start_line,
|
|
198
|
+
operator: m.operator.to_s,
|
|
199
|
+
token: token,
|
|
200
|
+
id: result.id
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
117
204
|
# Builds a line-aligned diff for a mutation whose byte range may span several
|
|
118
205
|
# lines (e.g. statement-removal of a multi-line statement). Returns the
|
|
119
206
|
# mutation's 1-based start line, the full original line-block it touches, the
|
|
@@ -133,6 +220,11 @@ module Mutineer
|
|
|
133
220
|
[start_line, original_block, mutated_block, token]
|
|
134
221
|
end
|
|
135
222
|
|
|
223
|
+
# Builds no-coverage JSON.
|
|
224
|
+
#
|
|
225
|
+
# @api private
|
|
226
|
+
# @param result [Mutineer::Result] result object.
|
|
227
|
+
# @return [Hash] no-coverage JSON object.
|
|
136
228
|
def no_coverage_json(result)
|
|
137
229
|
m = result.mutation
|
|
138
230
|
file = result.subject.file
|
|
@@ -144,6 +236,10 @@ module Mutineer
|
|
|
144
236
|
}
|
|
145
237
|
end
|
|
146
238
|
|
|
239
|
+
# Writes the summary block.
|
|
240
|
+
#
|
|
241
|
+
# @param out [IO] output stream.
|
|
242
|
+
# @return [void]
|
|
147
243
|
def summary(out)
|
|
148
244
|
out.puts "Summary"
|
|
149
245
|
out.puts "-------"
|
|
@@ -151,12 +247,23 @@ module Mutineer
|
|
|
151
247
|
out.puts format("Survived: %-6d No coverage: %d", @agg.survived_count, @agg.no_coverage_count)
|
|
152
248
|
out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count,
|
|
153
249
|
@agg.errored_count + @agg.timeout_count)
|
|
250
|
+
# #9: a broken harness, not a coverage gap — report it distinctly from No coverage.
|
|
251
|
+
out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
|
|
252
|
+
# #10: equivalent mutants the user suppressed; excluded from the denominator.
|
|
253
|
+
out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
|
|
154
254
|
end
|
|
155
255
|
|
|
256
|
+
# Writes the score line.
|
|
257
|
+
#
|
|
258
|
+
# @param out [IO] output stream.
|
|
259
|
+
# @param err [IO] error stream.
|
|
260
|
+
# @return [void]
|
|
156
261
|
def score_line(out, err)
|
|
157
262
|
score = @agg.mutation_score
|
|
158
|
-
excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.
|
|
159
|
-
"#{@agg.
|
|
263
|
+
excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
|
|
264
|
+
"#{@agg.skipped_invalid_count} skipped, " \
|
|
265
|
+
"#{@agg.errored_count + @agg.timeout_count} errored, " \
|
|
266
|
+
"#{@agg.ignored_count} ignored excluded"
|
|
160
267
|
if score.nil?
|
|
161
268
|
out.puts "Mutation score: N/A (no covered mutants)"
|
|
162
269
|
err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
|
|
@@ -165,6 +272,52 @@ module Mutineer
|
|
|
165
272
|
end
|
|
166
273
|
end
|
|
167
274
|
|
|
275
|
+
# #11: one line per source after the global summary, so a multi-source run
|
|
276
|
+
# shows which file is weak. Omitted for a single-source run — the global
|
|
277
|
+
# summary already says everything (ponytail: no redundant one-line block).
|
|
278
|
+
# Writes the per-source breakdown.
|
|
279
|
+
#
|
|
280
|
+
# @param out [IO] output stream.
|
|
281
|
+
# @return [void]
|
|
282
|
+
def per_source(out)
|
|
283
|
+
sources = @agg.by_source
|
|
284
|
+
return if sources.size <= 1
|
|
285
|
+
|
|
286
|
+
out.puts
|
|
287
|
+
out.puts "Per-source"
|
|
288
|
+
out.puts "----------"
|
|
289
|
+
sources.sort.each do |file, agg|
|
|
290
|
+
score = agg.mutation_score
|
|
291
|
+
out.puts format("%s %s (%d killed / %d survived / %d no-cov)",
|
|
292
|
+
file, score.nil? ? "N/A" : "#{score}%",
|
|
293
|
+
agg.killed_count, agg.survived_count, agg.no_coverage_count)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# #13: the --baseline delta, appended after the normal report. Names every NEW
|
|
298
|
+
# survivor (subject (file:line) operator) and the score delta when it dropped,
|
|
299
|
+
# then a one-line REGRESSION/OK verdict so CI logs show which gate fired.
|
|
300
|
+
def baseline_section(out, delta)
|
|
301
|
+
out.puts
|
|
302
|
+
out.puts "Baseline comparison"
|
|
303
|
+
out.puts "-------------------"
|
|
304
|
+
out.puts "killed #{@agg.killed_count}, #{delta.new_survivors.size} new survivors vs baseline"
|
|
305
|
+
delta.new_survivors
|
|
306
|
+
.sort_by { |r| [r.subject.file, r.mutation.start_offset] }
|
|
307
|
+
.each do |r|
|
|
308
|
+
file = r.subject.file
|
|
309
|
+
source = @source_map[file] || File.read(file)
|
|
310
|
+
line, = diff_for(r.mutation, source)
|
|
311
|
+
out.puts " + #{r.subject.qualified_name} (#{file}:#{line}) #{r.mutation.operator}"
|
|
312
|
+
end
|
|
313
|
+
out.puts "score dropped #{delta.score_before}% -> #{delta.score_after}%" if delta.score_drop
|
|
314
|
+
out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Writes the survivors block.
|
|
318
|
+
#
|
|
319
|
+
# @param out [IO] output stream.
|
|
320
|
+
# @return [void]
|
|
168
321
|
def survivors(out)
|
|
169
322
|
mutants = @agg.surviving_mutants
|
|
170
323
|
return if mutants.empty?
|
|
@@ -179,6 +332,12 @@ module Mutineer
|
|
|
179
332
|
end
|
|
180
333
|
end
|
|
181
334
|
|
|
335
|
+
# Writes one survivor entry.
|
|
336
|
+
#
|
|
337
|
+
# @param out [IO] output stream.
|
|
338
|
+
# @param file [String] source file path.
|
|
339
|
+
# @param result [Mutineer::Result] survivor result.
|
|
340
|
+
# @return [void]
|
|
182
341
|
def survivor(out, file, result)
|
|
183
342
|
m = result.mutation
|
|
184
343
|
source = @source_map[file] || File.read(file)
|
|
@@ -190,6 +349,11 @@ module Mutineer
|
|
|
190
349
|
mutated_block.each_line { |l| out.puts " + #{l.chomp}" }
|
|
191
350
|
end
|
|
192
351
|
|
|
352
|
+
# Writes the final verdict line.
|
|
353
|
+
#
|
|
354
|
+
# @param out [IO] output stream.
|
|
355
|
+
# @param threshold [Float] score threshold.
|
|
356
|
+
# @return [void]
|
|
193
357
|
def verdict(out, threshold)
|
|
194
358
|
score = @agg.mutation_score
|
|
195
359
|
return if score.nil?
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -1,77 +1,173 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mutineer
|
|
4
|
-
# Immutable outcome of running one mutant.
|
|
5
|
-
# killed
|
|
6
|
-
# survived
|
|
7
|
-
# error
|
|
8
|
-
# timeout
|
|
9
|
-
# skipped
|
|
10
|
-
# no_coverage
|
|
4
|
+
# Immutable outcome of running one mutant. Seven distinct states:
|
|
5
|
+
# killed — a test failed/errored, so the mutation was caught.
|
|
6
|
+
# survived — every test passed, so the mutation went undetected.
|
|
7
|
+
# error — the child crashed (unhandled exception): exit status 2.
|
|
8
|
+
# timeout — the parent SIGKILLed a child that overran its wall clock.
|
|
9
|
+
# skipped — the mutated source failed to re-parse (invalid); no fork.
|
|
10
|
+
# no_coverage — no test exercises the mutated line; not run, not scored.
|
|
11
|
+
# uncapturable — the line's would-be covering test errored during capture
|
|
12
|
+
# (#9), so coverage was lost. Excluded from the
|
|
13
|
+
# denominator exactly like no_coverage, but reported
|
|
14
|
+
# separately: it signals a broken harness (a test that
|
|
15
|
+
# failed to run), not a genuine coverage gap.
|
|
16
|
+
# ignored — a known-equivalent mutant the user suppressed (#10), via
|
|
17
|
+
# an inline `# mutineer:disable-line` comment or a
|
|
18
|
+
# `.mutineer.yml` `ignore:` id. A pre-fork classification
|
|
19
|
+
# (never run); excluded from the denominator so a strong
|
|
20
|
+
# file can reach 100%.
|
|
11
21
|
#
|
|
12
22
|
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
13
23
|
# validity failure (counted separately by the reporter), error is a runtime
|
|
14
|
-
# crash. Never conflate them via `details` string parsing. `no_coverage`
|
|
15
|
-
# pre-fork selection
|
|
24
|
+
# crash. Never conflate them via `details` string parsing. `no_coverage` and
|
|
25
|
+
# `uncapturable` are pre-fork selection results (M3/#9): both excluded from
|
|
26
|
+
# the score denominator.
|
|
16
27
|
#
|
|
17
|
-
# `subject` and `
|
|
18
|
-
# (which only know the outcome); the orchestrator attaches
|
|
19
|
-
# `result.with(subject:, mutation:)` so the Reporter
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def self.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
29
|
+
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
30
|
+
# them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
31
|
+
# can render survivor diffs and emit the stable id. `id` is the content-based
|
|
32
|
+
# MutantId (#10).
|
|
33
|
+
Result = Data.define(:status, :details, :subject, :mutation, :id) do
|
|
34
|
+
# Builds a killed result.
|
|
35
|
+
#
|
|
36
|
+
# @return [Mutineer::Result] killed result.
|
|
37
|
+
def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
|
|
38
|
+
|
|
39
|
+
# Builds a survived result.
|
|
40
|
+
#
|
|
41
|
+
# @return [Mutineer::Result] survived result.
|
|
42
|
+
def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
|
|
43
|
+
|
|
44
|
+
# Builds an error result.
|
|
45
|
+
#
|
|
46
|
+
# @param details [String, nil] error details.
|
|
47
|
+
# @return [Mutineer::Result] error result.
|
|
48
|
+
def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil, id: nil)
|
|
49
|
+
|
|
50
|
+
# Builds a timeout result.
|
|
51
|
+
#
|
|
52
|
+
# @return [Mutineer::Result] timeout result.
|
|
53
|
+
def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
|
|
54
|
+
|
|
55
|
+
# Builds a skipped result.
|
|
56
|
+
#
|
|
57
|
+
# @param details [String, nil] skip details.
|
|
58
|
+
# @return [Mutineer::Result] skipped result.
|
|
59
|
+
def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil, id: nil)
|
|
60
|
+
|
|
61
|
+
# Builds a no_coverage result.
|
|
62
|
+
#
|
|
63
|
+
# @return [Mutineer::Result] no-coverage result.
|
|
64
|
+
def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
|
|
65
|
+
|
|
66
|
+
# Builds an uncapturable result.
|
|
67
|
+
#
|
|
68
|
+
# @return [Mutineer::Result] uncapturable result.
|
|
69
|
+
def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
70
|
+
|
|
71
|
+
# Builds an ignored result.
|
|
72
|
+
#
|
|
73
|
+
# @return [Mutineer::Result] ignored result.
|
|
74
|
+
def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] true when the status is killed.
|
|
77
|
+
def killed? = status == :killed
|
|
78
|
+
# @return [Boolean] true when the status is survived.
|
|
79
|
+
def survived? = status == :survived
|
|
80
|
+
# @return [Boolean] true when the status is error.
|
|
81
|
+
def error? = status == :error
|
|
82
|
+
# @return [Boolean] true when the status is timeout.
|
|
83
|
+
def timeout? = status == :timeout
|
|
84
|
+
# @return [Boolean] true when the status is skipped.
|
|
85
|
+
def skipped? = status == :skipped
|
|
86
|
+
# @return [Boolean] true when the status is no_coverage.
|
|
87
|
+
def no_coverage? = status == :no_coverage
|
|
88
|
+
# @return [Boolean] true when the status is uncapturable.
|
|
89
|
+
def uncapturable? = status == :uncapturable
|
|
90
|
+
# @return [Boolean] true when the status is ignored.
|
|
91
|
+
def ignored? = status == :ignored
|
|
92
|
+
|
|
34
93
|
end
|
|
35
94
|
|
|
36
95
|
# Aggregates a flat list of Results into counts, the mutation score, and the
|
|
37
96
|
# surviving-mutant list. The score denominator is killed + survived ONLY
|
|
38
|
-
# (KTD-4): no-coverage, skipped (invalid), errored,
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# "0
|
|
97
|
+
# (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout,
|
|
98
|
+
# and ignored (#10 equivalent-mutant suppression) are each excluded and
|
|
99
|
+
# surfaced separately — so suppressing every survivor reaches 100%. An empty
|
|
100
|
+
# denominator yields a nil score (rendered "N/A"), never 0.0 —
|
|
101
|
+
# distinguishing "no testable mutants" from "0% killed".
|
|
42
102
|
class AggregateResult
|
|
43
103
|
attr_reader :results
|
|
44
104
|
|
|
105
|
+
# Builds an aggregate from results.
|
|
106
|
+
#
|
|
107
|
+
# @param results [Array<Mutineer::Result>] classified results.
|
|
45
108
|
def initialize(results)
|
|
46
109
|
@results = results
|
|
47
110
|
@by_status = results.group_by(&:status)
|
|
48
111
|
end
|
|
49
112
|
|
|
113
|
+
# @return [Integer] killed count.
|
|
50
114
|
def killed_count = count(:killed)
|
|
115
|
+
# @return [Integer] survived count.
|
|
51
116
|
def survived_count = count(:survived)
|
|
117
|
+
# @return [Integer] no-coverage count.
|
|
52
118
|
def no_coverage_count = count(:no_coverage)
|
|
119
|
+
# @return [Integer] uncapturable count.
|
|
120
|
+
def uncapturable_count = count(:uncapturable)
|
|
121
|
+
# @return [Integer] skipped-invalid count.
|
|
53
122
|
def skipped_invalid_count = count(:skipped)
|
|
123
|
+
# @return [Integer] errored count.
|
|
54
124
|
def errored_count = count(:error)
|
|
125
|
+
# @return [Integer] timeout count.
|
|
55
126
|
def timeout_count = count(:timeout)
|
|
127
|
+
# @return [Integer] ignored count.
|
|
128
|
+
def ignored_count = count(:ignored)
|
|
56
129
|
|
|
57
130
|
# Every generated, classified mutation. NOT the score denominator.
|
|
131
|
+
#
|
|
132
|
+
# @return [Integer] total result count.
|
|
58
133
|
def total = @results.size
|
|
59
134
|
|
|
60
135
|
# The score denominator (also shown to the reader).
|
|
136
|
+
#
|
|
137
|
+
# @return [Integer] killed plus survived.
|
|
61
138
|
def covered_count = killed_count + survived_count
|
|
62
139
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
140
|
+
# Computes the mutation score.
|
|
141
|
+
#
|
|
142
|
+
# @return [Float, nil] score percentage or nil when nothing was testable.
|
|
65
143
|
def mutation_score
|
|
66
144
|
return nil if covered_count.zero?
|
|
67
145
|
|
|
68
146
|
(killed_count.to_f / covered_count * 100).round(1)
|
|
69
147
|
end
|
|
70
148
|
|
|
149
|
+
# Returns the surviving mutants.
|
|
150
|
+
#
|
|
151
|
+
# @return [Array<Mutineer::Result>] surviving results.
|
|
71
152
|
def surviving_mutants = @results.select(&:survived?)
|
|
72
153
|
|
|
154
|
+
# #11: split into { source_file => AggregateResult } so the Reporter
|
|
155
|
+
# (per-source breakdown) and #13 (per-source roll-up / baseline diff) can
|
|
156
|
+
# reuse the same aggregate math.
|
|
157
|
+
#
|
|
158
|
+
# @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
|
|
159
|
+
def by_source
|
|
160
|
+
@results.select { |r| r.subject }
|
|
161
|
+
.group_by { |r| r.subject.file }
|
|
162
|
+
.transform_values { |rs| AggregateResult.new(rs) }
|
|
163
|
+
end
|
|
164
|
+
|
|
73
165
|
private
|
|
74
166
|
|
|
167
|
+
# Counts results for a status.
|
|
168
|
+
#
|
|
169
|
+
# @param status [Symbol] result status.
|
|
170
|
+
# @return [Integer] count for that status.
|
|
75
171
|
def count(status) = (@by_status[status] || []).size
|
|
76
172
|
end
|
|
77
173
|
end
|