mutineer 0.7.0 → 0.9.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 +49 -0
- data/README.md +39 -4
- data/lib/mutineer/baseline.rb +29 -13
- data/lib/mutineer/changed_lines.rb +29 -12
- data/lib/mutineer/cli.rb +88 -20
- data/lib/mutineer/config.rb +24 -2
- data/lib/mutineer/coverage_map.rb +86 -4
- data/lib/mutineer/isolation.rb +89 -40
- data/lib/mutineer/minitest_integration.rb +13 -9
- data/lib/mutineer/mutant_id.rb +24 -5
- data/lib/mutineer/mutation.rb +12 -6
- data/lib/mutineer/mutator_registry.rb +33 -6
- 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/collection_method.rb +44 -0
- 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/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +22 -7
- data/lib/mutineer/mutators/statement_removal.rb +6 -9
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/pairing.rb +32 -13
- data/lib/mutineer/parser.rb +17 -7
- data/lib/mutineer/project.rb +73 -2
- data/lib/mutineer/reporter.rb +169 -0
- data/lib/mutineer/result.rb +100 -32
- data/lib/mutineer/runner.rb +29 -2
- 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 +51 -29
- data/lib/mutineer.rb +4 -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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "stringio"
|
|
5
|
+
require "cgi"
|
|
5
6
|
require_relative "mutation"
|
|
6
7
|
|
|
7
8
|
module Mutineer
|
|
@@ -25,6 +26,8 @@ module Mutineer
|
|
|
25
26
|
rendered =
|
|
26
27
|
if format == "json"
|
|
27
28
|
json_report(baseline)
|
|
29
|
+
elsif format == "html"
|
|
30
|
+
html_report
|
|
28
31
|
else
|
|
29
32
|
sio = StringIO.new
|
|
30
33
|
human_report(sio, err, threshold)
|
|
@@ -41,6 +44,12 @@ module Mutineer
|
|
|
41
44
|
end
|
|
42
45
|
end
|
|
43
46
|
|
|
47
|
+
# Renders the human report.
|
|
48
|
+
#
|
|
49
|
+
# @param out [IO] output stream.
|
|
50
|
+
# @param err [IO] error stream.
|
|
51
|
+
# @param threshold [Float] score threshold.
|
|
52
|
+
# @return [void]
|
|
44
53
|
def human_report(out, err, threshold)
|
|
45
54
|
if @agg.total.zero?
|
|
46
55
|
err.puts "No mutations generated — verify target files contain in-scope " \
|
|
@@ -75,6 +84,11 @@ module Mutineer
|
|
|
75
84
|
# Canonical machine-readable schema (KTD7). survivors/no_coverage are sorted
|
|
76
85
|
# by (file, line, operator) so output is byte-stable regardless of --jobs
|
|
77
86
|
# worker finish order (R22).
|
|
87
|
+
# Renders the JSON report.
|
|
88
|
+
#
|
|
89
|
+
# @api private
|
|
90
|
+
# @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
|
|
91
|
+
# @return [String] JSON text.
|
|
78
92
|
def json_report(baseline = nil)
|
|
79
93
|
killed = @agg.killed_count
|
|
80
94
|
survived = @agg.survived_count
|
|
@@ -117,6 +131,117 @@ module Mutineer
|
|
|
117
131
|
"#{JSON.generate(doc)}\n"
|
|
118
132
|
end
|
|
119
133
|
|
|
134
|
+
# #23: one self-contained HTML file (inline CSS, no external assets) — the
|
|
135
|
+
# overall score + summary counts, a per-source table, and every surviving
|
|
136
|
+
# mutant with its stable id and diff. All source/diff/identifier text is
|
|
137
|
+
# HTML-escaped (CGI.escapeHTML) so a `<`/`>` in source can never break the
|
|
138
|
+
# markup. Reuses survivor_json/per_source_json so one run yields one set of
|
|
139
|
+
# facts regardless of --format.
|
|
140
|
+
def html_report
|
|
141
|
+
score = @agg.mutation_score
|
|
142
|
+
survivors = @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
143
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator]] }
|
|
144
|
+
per_source = @agg.by_source.map { |file, agg| per_source_json(file, agg) }
|
|
145
|
+
.sort_by { |h| h[:file] }
|
|
146
|
+
|
|
147
|
+
<<~HTML
|
|
148
|
+
<!DOCTYPE html>
|
|
149
|
+
<html lang="en">
|
|
150
|
+
<head>
|
|
151
|
+
<meta charset="utf-8">
|
|
152
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
153
|
+
<title>Mutineer Mutation Report</title>
|
|
154
|
+
<style>
|
|
155
|
+
body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
|
156
|
+
margin: 2rem; color: #1b1b1b; background: #fafafa; }
|
|
157
|
+
h1 { margin: 0 0 .25rem; }
|
|
158
|
+
.score { font-size: 2.5rem; font-weight: 700; }
|
|
159
|
+
.counts { color: #444; margin: .5rem 0 1.5rem; }
|
|
160
|
+
.counts span { display: inline-block; margin-right: 1rem; white-space: nowrap; }
|
|
161
|
+
table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; background: #fff; }
|
|
162
|
+
th, td { border: 1px solid #ddd; padding: .4rem .6rem; text-align: left; }
|
|
163
|
+
th { background: #f0f0f0; }
|
|
164
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
165
|
+
.survivor { background: #fff; border: 1px solid #ddd; border-radius: 4px;
|
|
166
|
+
padding: .75rem 1rem; margin-bottom: 1rem; }
|
|
167
|
+
.survivor h3 { margin: 0 0 .25rem; font-size: 1rem; }
|
|
168
|
+
.meta { color: #555; font-size: .85rem; margin-bottom: .5rem; }
|
|
169
|
+
.id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
170
|
+
pre.diff { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
171
|
+
background: #f6f8fa; padding: .5rem .75rem; overflow-x: auto;
|
|
172
|
+
margin: 0; border-radius: 4px; }
|
|
173
|
+
.diff .add { color: #116329; }
|
|
174
|
+
.diff .del { color: #82071e; }
|
|
175
|
+
</style>
|
|
176
|
+
</head>
|
|
177
|
+
<body>
|
|
178
|
+
<h1>Mutineer — Mutation Report</h1>
|
|
179
|
+
<div class="score">Score: #{score.nil? ? 'N/A' : "#{score}%"}</div>
|
|
180
|
+
#{summary_html}
|
|
181
|
+
#{per_source_html(per_source)}
|
|
182
|
+
#{survivors_html(survivors)}
|
|
183
|
+
</body>
|
|
184
|
+
</html>
|
|
185
|
+
HTML
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The summary counts block for the HTML header.
|
|
189
|
+
def summary_html
|
|
190
|
+
counts = {
|
|
191
|
+
"total" => @agg.total, "killed" => @agg.killed_count,
|
|
192
|
+
"survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
|
|
193
|
+
"uncapturable" => @agg.uncapturable_count, "ignored" => @agg.ignored_count,
|
|
194
|
+
"skipped" => @agg.skipped_invalid_count,
|
|
195
|
+
"errored" => @agg.errored_count + @agg.timeout_count
|
|
196
|
+
}
|
|
197
|
+
spans = counts.map { |k, v| "<span><strong>#{v}</strong> #{esc(k)}</span>" }.join("\n ")
|
|
198
|
+
"<div class=\"counts\">\n #{spans}\n</div>"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The per-source breakdown table.
|
|
202
|
+
def per_source_html(per_source)
|
|
203
|
+
return "" if per_source.empty?
|
|
204
|
+
|
|
205
|
+
rows = per_source.map do |h|
|
|
206
|
+
score = h[:score].nil? ? "N/A" : "#{h[:score]}%"
|
|
207
|
+
"<tr><td>#{esc(h[:file])}</td><td class=\"num\">#{score}</td>" \
|
|
208
|
+
"<td class=\"num\">#{h[:killed]}</td><td class=\"num\">#{h[:survived]}</td>" \
|
|
209
|
+
"<td class=\"num\">#{h[:no_coverage]}</td></tr>"
|
|
210
|
+
end.join("\n ")
|
|
211
|
+
<<~HTML.chomp
|
|
212
|
+
<h2>Per-source</h2>
|
|
213
|
+
<table>
|
|
214
|
+
<tr><th>File</th><th>Score</th><th>Killed</th><th>Survived</th><th>No coverage</th></tr>
|
|
215
|
+
#{rows}
|
|
216
|
+
</table>
|
|
217
|
+
HTML
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The surviving-mutants list, each with subject, location, operator, stable
|
|
221
|
+
# id, and a colorized diff. All text is HTML-escaped.
|
|
222
|
+
def survivors_html(survivors)
|
|
223
|
+
return "<h2>Surviving Mutants</h2>\n<p>None — every covered mutant was killed.</p>" if survivors.empty?
|
|
224
|
+
|
|
225
|
+
cards = survivors.map do |s|
|
|
226
|
+
diff_lines = s[:diff].each_line.map do |line|
|
|
227
|
+
cls = line.start_with?("+") ? "add" : (line.start_with?("-") ? "del" : nil)
|
|
228
|
+
text = esc(line.chomp)
|
|
229
|
+
cls ? "<span class=\"#{cls}\">#{text}</span>" : text
|
|
230
|
+
end.join("\n")
|
|
231
|
+
<<~CARD.chomp
|
|
232
|
+
<div class="survivor">
|
|
233
|
+
<h3>#{esc(s[:subject])}</h3>
|
|
234
|
+
<div class="meta">#{esc(s[:file])}:#{s[:line]} · #{esc(s[:operator])} · <span class="id">#{esc(s[:id])}</span></div>
|
|
235
|
+
<pre class="diff">#{diff_lines}</pre>
|
|
236
|
+
</div>
|
|
237
|
+
CARD
|
|
238
|
+
end.join("\n")
|
|
239
|
+
"<h2>Surviving Mutants</h2>\n#{cards}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# HTML-escapes any text destined for the document (stdlib CGI).
|
|
243
|
+
def esc(text) = CGI.escapeHTML(text.to_s)
|
|
244
|
+
|
|
120
245
|
# #13: the same delta facts the human report prints, for dashboards. new_survivors
|
|
121
246
|
# reuse the ignored_json shape (subject/file/line/operator/token/id) and sort
|
|
122
247
|
# byte-stably so output doesn't depend on --jobs finish order.
|
|
@@ -135,6 +260,12 @@ module Mutineer
|
|
|
135
260
|
}
|
|
136
261
|
end
|
|
137
262
|
|
|
263
|
+
# Builds per-source JSON.
|
|
264
|
+
#
|
|
265
|
+
# @api private
|
|
266
|
+
# @param file [String] source file path.
|
|
267
|
+
# @param agg [Mutineer::AggregateResult] source aggregate.
|
|
268
|
+
# @return [Hash] per-source JSON object.
|
|
138
269
|
def per_source_json(file, agg)
|
|
139
270
|
{
|
|
140
271
|
file: file, total: agg.total,
|
|
@@ -143,6 +274,11 @@ module Mutineer
|
|
|
143
274
|
}
|
|
144
275
|
end
|
|
145
276
|
|
|
277
|
+
# Builds survivor JSON.
|
|
278
|
+
#
|
|
279
|
+
# @api private
|
|
280
|
+
# @param result [Mutineer::Result] survivor result.
|
|
281
|
+
# @return [Hash] survivor JSON object.
|
|
146
282
|
def survivor_json(result)
|
|
147
283
|
m = result.mutation
|
|
148
284
|
file = result.subject.file
|
|
@@ -198,6 +334,11 @@ module Mutineer
|
|
|
198
334
|
[start_line, original_block, mutated_block, token]
|
|
199
335
|
end
|
|
200
336
|
|
|
337
|
+
# Builds no-coverage JSON.
|
|
338
|
+
#
|
|
339
|
+
# @api private
|
|
340
|
+
# @param result [Mutineer::Result] result object.
|
|
341
|
+
# @return [Hash] no-coverage JSON object.
|
|
201
342
|
def no_coverage_json(result)
|
|
202
343
|
m = result.mutation
|
|
203
344
|
file = result.subject.file
|
|
@@ -209,6 +350,10 @@ module Mutineer
|
|
|
209
350
|
}
|
|
210
351
|
end
|
|
211
352
|
|
|
353
|
+
# Writes the summary block.
|
|
354
|
+
#
|
|
355
|
+
# @param out [IO] output stream.
|
|
356
|
+
# @return [void]
|
|
212
357
|
def summary(out)
|
|
213
358
|
out.puts "Summary"
|
|
214
359
|
out.puts "-------"
|
|
@@ -222,6 +367,11 @@ module Mutineer
|
|
|
222
367
|
out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
|
|
223
368
|
end
|
|
224
369
|
|
|
370
|
+
# Writes the score line.
|
|
371
|
+
#
|
|
372
|
+
# @param out [IO] output stream.
|
|
373
|
+
# @param err [IO] error stream.
|
|
374
|
+
# @return [void]
|
|
225
375
|
def score_line(out, err)
|
|
226
376
|
score = @agg.mutation_score
|
|
227
377
|
excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
|
|
@@ -239,6 +389,10 @@ module Mutineer
|
|
|
239
389
|
# #11: one line per source after the global summary, so a multi-source run
|
|
240
390
|
# shows which file is weak. Omitted for a single-source run — the global
|
|
241
391
|
# summary already says everything (ponytail: no redundant one-line block).
|
|
392
|
+
# Writes the per-source breakdown.
|
|
393
|
+
#
|
|
394
|
+
# @param out [IO] output stream.
|
|
395
|
+
# @return [void]
|
|
242
396
|
def per_source(out)
|
|
243
397
|
sources = @agg.by_source
|
|
244
398
|
return if sources.size <= 1
|
|
@@ -274,6 +428,10 @@ module Mutineer
|
|
|
274
428
|
out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
|
|
275
429
|
end
|
|
276
430
|
|
|
431
|
+
# Writes the survivors block.
|
|
432
|
+
#
|
|
433
|
+
# @param out [IO] output stream.
|
|
434
|
+
# @return [void]
|
|
277
435
|
def survivors(out)
|
|
278
436
|
mutants = @agg.surviving_mutants
|
|
279
437
|
return if mutants.empty?
|
|
@@ -288,6 +446,12 @@ module Mutineer
|
|
|
288
446
|
end
|
|
289
447
|
end
|
|
290
448
|
|
|
449
|
+
# Writes one survivor entry.
|
|
450
|
+
#
|
|
451
|
+
# @param out [IO] output stream.
|
|
452
|
+
# @param file [String] source file path.
|
|
453
|
+
# @param result [Mutineer::Result] survivor result.
|
|
454
|
+
# @return [void]
|
|
291
455
|
def survivor(out, file, result)
|
|
292
456
|
m = result.mutation
|
|
293
457
|
source = @source_map[file] || File.read(file)
|
|
@@ -299,6 +463,11 @@ module Mutineer
|
|
|
299
463
|
mutated_block.each_line { |l| out.puts " + #{l.chomp}" }
|
|
300
464
|
end
|
|
301
465
|
|
|
466
|
+
# Writes the final verdict line.
|
|
467
|
+
#
|
|
468
|
+
# @param out [IO] output stream.
|
|
469
|
+
# @param threshold [Float] score threshold.
|
|
470
|
+
# @return [void]
|
|
302
471
|
def verdict(out, threshold)
|
|
303
472
|
score = @agg.mutation_score
|
|
304
473
|
return if score.nil?
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -8,90 +8,154 @@ module Mutineer
|
|
|
8
8
|
# timeout — the parent SIGKILLed a child that overran its wall clock.
|
|
9
9
|
# skipped — the mutated source failed to re-parse (invalid); no fork.
|
|
10
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
|
-
# so coverage was lost. Excluded from the
|
|
13
|
-
# like no_coverage, but reported
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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%.
|
|
19
21
|
#
|
|
20
22
|
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
21
23
|
# validity failure (counted separately by the reporter), error is a runtime
|
|
22
24
|
# crash. Never conflate them via `details` string parsing. `no_coverage` and
|
|
23
|
-
# `uncapturable` are pre-fork selection results (M3/#9): both excluded from
|
|
24
|
-
# score denominator.
|
|
25
|
+
# `uncapturable` are pre-fork selection results (M3/#9): both excluded from
|
|
26
|
+
# the score denominator.
|
|
25
27
|
#
|
|
26
|
-
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
27
|
-
# Runner (which only know the outcome); the orchestrator attaches
|
|
28
|
-
# via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
29
|
-
# diffs and emit the stable id. `id` is the content-based
|
|
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).
|
|
30
33
|
Result = Data.define(:status, :details, :subject, :mutation, :id) do
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
33
48
|
def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil, id: nil)
|
|
34
|
-
|
|
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.
|
|
35
59
|
def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil, id: nil)
|
|
36
|
-
def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
|
|
37
|
-
def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
38
|
-
def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
|
|
39
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.
|
|
40
77
|
def killed? = status == :killed
|
|
78
|
+
# @return [Boolean] true when the status is survived.
|
|
41
79
|
def survived? = status == :survived
|
|
80
|
+
# @return [Boolean] true when the status is error.
|
|
42
81
|
def error? = status == :error
|
|
82
|
+
# @return [Boolean] true when the status is timeout.
|
|
43
83
|
def timeout? = status == :timeout
|
|
84
|
+
# @return [Boolean] true when the status is skipped.
|
|
44
85
|
def skipped? = status == :skipped
|
|
86
|
+
# @return [Boolean] true when the status is no_coverage.
|
|
45
87
|
def no_coverage? = status == :no_coverage
|
|
88
|
+
# @return [Boolean] true when the status is uncapturable.
|
|
46
89
|
def uncapturable? = status == :uncapturable
|
|
90
|
+
# @return [Boolean] true when the status is ignored.
|
|
47
91
|
def ignored? = status == :ignored
|
|
92
|
+
|
|
48
93
|
end
|
|
49
94
|
|
|
50
95
|
# Aggregates a flat list of Results into counts, the mutation score, and the
|
|
51
96
|
# surviving-mutant list. The score denominator is killed + survived ONLY
|
|
52
|
-
# (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout,
|
|
53
|
-
# ignored (#10 equivalent-mutant suppression) are each excluded and
|
|
54
|
-
# separately — so suppressing every survivor reaches 100%. An empty
|
|
55
|
-
# (rendered "N/A"), never 0.0 —
|
|
56
|
-
# "0% killed".
|
|
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".
|
|
57
102
|
class AggregateResult
|
|
58
103
|
attr_reader :results
|
|
59
104
|
|
|
105
|
+
# Builds an aggregate from results.
|
|
106
|
+
#
|
|
107
|
+
# @param results [Array<Mutineer::Result>] classified results.
|
|
60
108
|
def initialize(results)
|
|
61
109
|
@results = results
|
|
62
110
|
@by_status = results.group_by(&:status)
|
|
63
111
|
end
|
|
64
112
|
|
|
113
|
+
# @return [Integer] killed count.
|
|
65
114
|
def killed_count = count(:killed)
|
|
115
|
+
# @return [Integer] survived count.
|
|
66
116
|
def survived_count = count(:survived)
|
|
117
|
+
# @return [Integer] no-coverage count.
|
|
67
118
|
def no_coverage_count = count(:no_coverage)
|
|
119
|
+
# @return [Integer] uncapturable count.
|
|
68
120
|
def uncapturable_count = count(:uncapturable)
|
|
121
|
+
# @return [Integer] skipped-invalid count.
|
|
69
122
|
def skipped_invalid_count = count(:skipped)
|
|
123
|
+
# @return [Integer] errored count.
|
|
70
124
|
def errored_count = count(:error)
|
|
125
|
+
# @return [Integer] timeout count.
|
|
71
126
|
def timeout_count = count(:timeout)
|
|
127
|
+
# @return [Integer] ignored count.
|
|
72
128
|
def ignored_count = count(:ignored)
|
|
73
129
|
|
|
74
130
|
# Every generated, classified mutation. NOT the score denominator.
|
|
131
|
+
#
|
|
132
|
+
# @return [Integer] total result count.
|
|
75
133
|
def total = @results.size
|
|
76
134
|
|
|
77
135
|
# The score denominator (also shown to the reader).
|
|
136
|
+
#
|
|
137
|
+
# @return [Integer] killed plus survived.
|
|
78
138
|
def covered_count = killed_count + survived_count
|
|
79
139
|
|
|
80
|
-
#
|
|
81
|
-
#
|
|
140
|
+
# Computes the mutation score.
|
|
141
|
+
#
|
|
142
|
+
# @return [Float, nil] score percentage or nil when nothing was testable.
|
|
82
143
|
def mutation_score
|
|
83
144
|
return nil if covered_count.zero?
|
|
84
145
|
|
|
85
146
|
(killed_count.to_f / covered_count * 100).round(1)
|
|
86
147
|
end
|
|
87
148
|
|
|
149
|
+
# Returns the surviving mutants.
|
|
150
|
+
#
|
|
151
|
+
# @return [Array<Mutineer::Result>] surviving results.
|
|
88
152
|
def surviving_mutants = @results.select(&:survived?)
|
|
89
153
|
|
|
90
|
-
# #11: split into { source_file => AggregateResult } so the Reporter
|
|
91
|
-
# breakdown) and #13 (per-source roll-up / baseline diff)
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
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.
|
|
95
159
|
def by_source
|
|
96
160
|
@results.select { |r| r.subject }
|
|
97
161
|
.group_by { |r| r.subject.file }
|
|
@@ -100,6 +164,10 @@ module Mutineer
|
|
|
100
164
|
|
|
101
165
|
private
|
|
102
166
|
|
|
167
|
+
# Counts results for a status.
|
|
168
|
+
#
|
|
169
|
+
# @param status [Symbol] result status.
|
|
170
|
+
# @return [Integer] count for that status.
|
|
103
171
|
def count(status) = (@by_status[status] || []).size
|
|
104
172
|
end
|
|
105
173
|
end
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -32,6 +32,9 @@ module Mutineer
|
|
|
32
32
|
# The parent process `require`s each source file so its classes exist; forked
|
|
33
33
|
# children inherit them, so a covering test file's own require_relative of the
|
|
34
34
|
# source is a no-op and does not clobber the mutated `load` (spec §7).
|
|
35
|
+
#
|
|
36
|
+
# @param config [Mutineer::Config] run configuration.
|
|
37
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String, String>)] aggregate and source map.
|
|
35
38
|
def self.execute(config)
|
|
36
39
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
37
40
|
|
|
@@ -124,13 +127,17 @@ module Mutineer
|
|
|
124
127
|
results =
|
|
125
128
|
begin
|
|
126
129
|
framework = config.framework
|
|
127
|
-
|
|
130
|
+
# #21: --fail-fast stops scheduling new mutants after the first survivor;
|
|
131
|
+
# in-flight workers drain, unscheduled jobs stay nil (dropped below).
|
|
132
|
+
stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
|
|
133
|
+
bare = WorkerPool.new(config.jobs).run(jobs, stop_when: stop_when) do |subject, mutation|
|
|
128
134
|
run(mutation, source_file: subject.file, coverage_map: coverage_map,
|
|
129
135
|
subject: subject, strategy: strategy, rails: config.rails, framework: framework)
|
|
130
136
|
end
|
|
131
137
|
# The bare Results carry only status (Subjects hold live AST nodes that
|
|
132
138
|
# do not marshal); reattach subject+mutation+id in the parent, in order.
|
|
133
|
-
|
|
139
|
+
# filter_map drops nils for jobs --fail-fast left unscheduled.
|
|
140
|
+
bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
|
|
134
141
|
ensure
|
|
135
142
|
sweep_orphans(source_dirs)
|
|
136
143
|
end
|
|
@@ -215,6 +222,11 @@ module Mutineer
|
|
|
215
222
|
warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
|
|
216
223
|
end
|
|
217
224
|
|
|
225
|
+
# Removes stale mutant tempfiles from the given directories.
|
|
226
|
+
#
|
|
227
|
+
# @api private
|
|
228
|
+
# @param dirs [Array<String>] directories to sweep.
|
|
229
|
+
# @return [void]
|
|
218
230
|
def self.sweep_orphans(dirs)
|
|
219
231
|
dirs.each do |dir|
|
|
220
232
|
Dir.glob(File.join(dir, "mutineer_mutant*.rb")).each do |f|
|
|
@@ -223,6 +235,17 @@ module Mutineer
|
|
|
223
235
|
end
|
|
224
236
|
end
|
|
225
237
|
|
|
238
|
+
# Runs a single mutation through isolation.
|
|
239
|
+
#
|
|
240
|
+
# @param mutation [Mutineer::Mutation] mutation to run.
|
|
241
|
+
# @param source_file [String] source file path.
|
|
242
|
+
# @param coverage_map [Mutineer::CoverageMap, nil] coverage map.
|
|
243
|
+
# @param subject [Mutineer::Subject, nil] subject for surgical strategy.
|
|
244
|
+
# @param strategy [String] mutation strategy.
|
|
245
|
+
# @param timeout [Integer] child timeout in seconds.
|
|
246
|
+
# @param rails [Boolean] whether Rails reconnect handling is enabled.
|
|
247
|
+
# @param framework [String] test framework name.
|
|
248
|
+
# @return [Mutineer::Result] mutant result.
|
|
226
249
|
def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
|
|
227
250
|
timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest")
|
|
228
251
|
source = File.read(source_file)
|
|
@@ -257,6 +280,10 @@ module Mutineer
|
|
|
257
280
|
end
|
|
258
281
|
end
|
|
259
282
|
|
|
283
|
+
# Reconnects ActiveRecord in a forked child when available.
|
|
284
|
+
#
|
|
285
|
+
# @api private
|
|
286
|
+
# @return [void]
|
|
260
287
|
def self.reconnect_active_record
|
|
261
288
|
return unless defined?(ActiveRecord::Base)
|
|
262
289
|
|