mutineer 0.2.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 +7 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/cli.rb +261 -0
- data/lib/mutineer/config.rb +145 -0
- data/lib/mutineer/coverage_map.rb +222 -0
- data/lib/mutineer/isolation.rb +157 -0
- data/lib/mutineer/minitest_integration.rb +43 -0
- data/lib/mutineer/mutation.rb +21 -0
- data/lib/mutineer/mutator_registry.rb +54 -0
- data/lib/mutineer/mutators/arithmetic.rb +29 -0
- data/lib/mutineer/mutators/base.rb +23 -0
- data/lib/mutineer/mutators/boolean_connector.rb +42 -0
- data/lib/mutineer/mutators/boolean_literal.rb +43 -0
- data/lib/mutineer/mutators/comparison.rb +36 -0
- data/lib/mutineer/mutators/condition_negation.rb +40 -0
- data/lib/mutineer/mutators/literal_mutation.rb +40 -0
- data/lib/mutineer/mutators/return_nil.rb +63 -0
- data/lib/mutineer/mutators/statement_removal.rb +34 -0
- data/lib/mutineer/parser.rb +26 -0
- data/lib/mutineer/project.rb +69 -0
- data/lib/mutineer/reporter.rb +204 -0
- data/lib/mutineer/result.rb +77 -0
- data/lib/mutineer/runner.rb +175 -0
- data/lib/mutineer/subject.rb +19 -0
- data/lib/mutineer/version.rb +5 -0
- data/lib/mutineer/worker_pool.rb +112 -0
- data/lib/mutineer.rb +29 -0
- metadata +104 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Condition-negation operator (Tier 2, OFF by default). Wraps an if/unless/
|
|
8
|
+
# ternary condition in `!( ... )` textually. Ruby ternaries parse as IfNode in
|
|
9
|
+
# Prism, so visit_if_node covers them too (R12). The standard validity re-parse
|
|
10
|
+
# downstream discards any wrap that fails to round-trip (R14).
|
|
11
|
+
#
|
|
12
|
+
# Clean-room: from the spec's operator description, not the mutant gem.
|
|
13
|
+
class ConditionNegation < Base
|
|
14
|
+
def visit_if_node(node)
|
|
15
|
+
wrap(node.predicate)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def visit_unless_node(node)
|
|
20
|
+
wrap(node.predicate)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def wrap(predicate)
|
|
27
|
+
return unless predicate
|
|
28
|
+
|
|
29
|
+
loc = predicate.location
|
|
30
|
+
original = @source.byteslice(loc.start_offset...loc.end_offset)
|
|
31
|
+
@mutations << Mutation.new(
|
|
32
|
+
start_offset: loc.start_offset,
|
|
33
|
+
end_offset: loc.end_offset,
|
|
34
|
+
replacement: "!( #{original} )",
|
|
35
|
+
operator: :condition_negation
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Literal-fuzzing operator (Tier 2, OFF by default). Integers emit up to
|
|
8
|
+
# three mutations (0, 1, n+1) with no-op guards for 0 and 1; strings collapse
|
|
9
|
+
# to "" unless already empty. One mutation per emitted candidate (R11).
|
|
10
|
+
#
|
|
11
|
+
# Clean-room: from the spec's operator description, not the mutant gem.
|
|
12
|
+
class LiteralMutation < Base
|
|
13
|
+
def visit_integer_node(node)
|
|
14
|
+
n = node.value
|
|
15
|
+
emit(node.location, "0") unless n.zero?
|
|
16
|
+
emit(node.location, "1") unless n == 1
|
|
17
|
+
emit(node.location, (n + 1).to_s)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def visit_string_node(node)
|
|
22
|
+
loc = node.location
|
|
23
|
+
token = @source.byteslice(loc.start_offset...loc.end_offset)
|
|
24
|
+
emit(loc, '""') unless token == '""' || token == "''" || node.unescaped.empty?
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def emit(loc, replacement)
|
|
31
|
+
@mutations << Mutation.new(
|
|
32
|
+
start_offset: loc.start_offset,
|
|
33
|
+
end_offset: loc.end_offset,
|
|
34
|
+
replacement: replacement,
|
|
35
|
+
operator: :literal_mutation
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Return-value-nil operator (Tier 2, OFF by default). Two rules:
|
|
8
|
+
# 1. an explicit `return <expr>` -> `return nil`, unless the value is
|
|
9
|
+
# already nil (no-op guard).
|
|
10
|
+
# 2. a method body whose final expression is neither a ReturnNode nor a
|
|
11
|
+
# NilNode -> that expression becomes `nil`.
|
|
12
|
+
# Nested defs are their own subjects, so we never descend into them (R10).
|
|
13
|
+
#
|
|
14
|
+
# Clean-room: from the spec's operator description, not the mutant gem.
|
|
15
|
+
class ReturnNil < Base
|
|
16
|
+
def mutations_for(subject, source)
|
|
17
|
+
@source = source
|
|
18
|
+
@mutations = []
|
|
19
|
+
body = subject.def_node.body
|
|
20
|
+
if body
|
|
21
|
+
body.accept(self) # rule 1 (explicit return nodes in this body)
|
|
22
|
+
final_expression_nil(body) # rule 2 (method's final expression)
|
|
23
|
+
end
|
|
24
|
+
@mutations
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def visit_return_node(node)
|
|
28
|
+
args = node.arguments
|
|
29
|
+
if args
|
|
30
|
+
values = args.arguments
|
|
31
|
+
unless values.size == 1 && values.first.is_a?(Prism::NilNode)
|
|
32
|
+
emit(args.location)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Nested method definitions are discovered as their own subjects; do not
|
|
39
|
+
# recurse into them (prevents double-counting their statements).
|
|
40
|
+
def visit_def_node(node); end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def final_expression_nil(body)
|
|
45
|
+
return unless body.is_a?(Prism::StatementsNode)
|
|
46
|
+
|
|
47
|
+
last = body.body.last
|
|
48
|
+
return if last.nil? || last.is_a?(Prism::ReturnNode) || last.is_a?(Prism::NilNode)
|
|
49
|
+
|
|
50
|
+
emit(last.location)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def emit(loc)
|
|
54
|
+
@mutations << Mutation.new(
|
|
55
|
+
start_offset: loc.start_offset,
|
|
56
|
+
end_offset: loc.end_offset,
|
|
57
|
+
replacement: "nil",
|
|
58
|
+
operator: :return_nil
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Statement-removal operator: replace each non-final method statement with
|
|
8
|
+
# "nil". Tests whether the suite detects a missing side effect. The final
|
|
9
|
+
# expression is always skipped — replacing the return value with nil is the
|
|
10
|
+
# M5 return-nil operator's distinct concern (KTD-1). A body with < 2
|
|
11
|
+
# statements has no non-final statement, so it generates nothing.
|
|
12
|
+
#
|
|
13
|
+
# Clean-room: from the spec's operator description, not the mutant gem.
|
|
14
|
+
class StatementRemoval < Base
|
|
15
|
+
def visit_statements_node(node)
|
|
16
|
+
stmts = node.body
|
|
17
|
+
return if stmts.length < 2
|
|
18
|
+
|
|
19
|
+
stmts[0...-1].each do |stmt|
|
|
20
|
+
loc = stmt.location
|
|
21
|
+
@mutations << Mutation.new(
|
|
22
|
+
start_offset: loc.start_offset,
|
|
23
|
+
end_offset: loc.end_offset,
|
|
24
|
+
replacement: "nil",
|
|
25
|
+
operator: :statement_removal
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
# ponytail: no super — recursing into a nested StatementsNode would
|
|
29
|
+
# re-emit removals already covered at the top level and double-count.
|
|
30
|
+
# Each subject's body is visited once.
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
# Raised only for I/O failures while reading a source file. Prism syntax
|
|
7
|
+
# errors are NOT raised — they are in-band via ParseResult#errors.
|
|
8
|
+
class ParseError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Thin boundary around Prism. Both methods return a Prism::ParseResult so all
|
|
11
|
+
# callers use result.value (AST root), result.source.source (raw bytes), and
|
|
12
|
+
# result.errors uniformly. No wrapping struct.
|
|
13
|
+
class Parser
|
|
14
|
+
# Returns Prism::ParseResult. Re-raises I/O failures as Mutineer::ParseError.
|
|
15
|
+
def self.parse_file(path)
|
|
16
|
+
Prism.parse_file(path)
|
|
17
|
+
rescue SystemCallError => e
|
|
18
|
+
raise ParseError, e.message
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns Prism::ParseResult. Never raises; callers check .errors.empty?.
|
|
22
|
+
def self.parse_string(source)
|
|
23
|
+
Prism.parse(source)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "parser"
|
|
5
|
+
require_relative "subject"
|
|
6
|
+
|
|
7
|
+
module Mutineer
|
|
8
|
+
# Subject discovery: parse each path and walk its AST for method definitions,
|
|
9
|
+
# tracking the enclosing class/module namespace.
|
|
10
|
+
class Project
|
|
11
|
+
# Returns Array<Subject>. `only` filters by qualified name (string equality).
|
|
12
|
+
def self.discover(paths, only: nil)
|
|
13
|
+
subjects = Array(paths).flat_map do |path|
|
|
14
|
+
result = Parser.parse_file(path)
|
|
15
|
+
visitor = SubjectVisitor.new(path)
|
|
16
|
+
visitor.visit(result.value)
|
|
17
|
+
visitor.subjects
|
|
18
|
+
end
|
|
19
|
+
only ? subjects.select { |s| s.qualified_name == only } : subjects
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Walks an AST, maintaining a namespace stack, emitting Subjects.
|
|
23
|
+
# Nested inside Project to signal its private role.
|
|
24
|
+
class SubjectVisitor < Prism::Visitor
|
|
25
|
+
attr_reader :subjects
|
|
26
|
+
|
|
27
|
+
def initialize(file)
|
|
28
|
+
@file = file
|
|
29
|
+
@namespace_stack = []
|
|
30
|
+
@subjects = []
|
|
31
|
+
super()
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def visit_class_node(node)
|
|
35
|
+
@namespace_stack.push(extract_constant_name(node.constant_path))
|
|
36
|
+
super
|
|
37
|
+
@namespace_stack.pop
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def visit_module_node(node)
|
|
41
|
+
@namespace_stack.push(extract_constant_name(node.constant_path))
|
|
42
|
+
super
|
|
43
|
+
@namespace_stack.pop
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def visit_def_node(node)
|
|
47
|
+
@subjects << Subject.new(
|
|
48
|
+
file: @file,
|
|
49
|
+
namespace: @namespace_stack.dup,
|
|
50
|
+
name: node.name,
|
|
51
|
+
singleton: !node.receiver.nil?,
|
|
52
|
+
def_node: node
|
|
53
|
+
)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def extract_constant_name(node)
|
|
60
|
+
case node
|
|
61
|
+
when Prism::ConstantReadNode
|
|
62
|
+
node.name.to_s
|
|
63
|
+
when Prism::ConstantPathNode
|
|
64
|
+
[extract_constant_name(node.parent), node.name.to_s].compact.join("::")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require_relative "mutation"
|
|
6
|
+
|
|
7
|
+
module Mutineer
|
|
8
|
+
# Renders an AggregateResult: the summary block, mutation score, and per-file
|
|
9
|
+
# survivor diffs. Stream discipline (R14): the report goes to `out` (stdout),
|
|
10
|
+
# diagnostics/warnings go to `err` (stderr), so `mutineer ... > report.txt`
|
|
11
|
+
# captures only the report.
|
|
12
|
+
#
|
|
13
|
+
# `source_map` is { file_path => raw source string }, used to extract the
|
|
14
|
+
# containing source line for each survivor diff.
|
|
15
|
+
class Reporter
|
|
16
|
+
def initialize(aggregate, source_map)
|
|
17
|
+
@agg = aggregate
|
|
18
|
+
@source_map = source_map
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Single entry point (R20/R21). Branches on `format` ("human" | "json") and
|
|
22
|
+
# routes the rendered report to `output` (a file, with a stderr confirmation)
|
|
23
|
+
# or to `out`. Diagnostics always go to `err`.
|
|
24
|
+
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil)
|
|
25
|
+
rendered =
|
|
26
|
+
if format == "json"
|
|
27
|
+
json_report
|
|
28
|
+
else
|
|
29
|
+
sio = StringIO.new
|
|
30
|
+
human_report(sio, err, threshold)
|
|
31
|
+
sio.string
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if output
|
|
35
|
+
abs = File.expand_path(output)
|
|
36
|
+
File.write(abs, rendered)
|
|
37
|
+
err.puts "Report written to #{abs}"
|
|
38
|
+
else
|
|
39
|
+
out.print rendered
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def human_report(out, err, threshold)
|
|
44
|
+
if @agg.total.zero?
|
|
45
|
+
err.puts "No mutations generated — verify target files contain in-scope " \
|
|
46
|
+
"operators and are reached by the suite."
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
out.puts "Mutineer — Mutation Results"
|
|
51
|
+
out.puts "========================="
|
|
52
|
+
out.puts
|
|
53
|
+
summary(out)
|
|
54
|
+
out.puts
|
|
55
|
+
score_line(out, err)
|
|
56
|
+
|
|
57
|
+
survivors(out)
|
|
58
|
+
verdict(out, threshold) if threshold && threshold.positive?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 0 pass / 1 below threshold. Usage errors (exit 2) are the CLI's job.
|
|
62
|
+
def exit_code(threshold:)
|
|
63
|
+
return 0 if threshold.nil? || threshold <= 0
|
|
64
|
+
|
|
65
|
+
score = @agg.mutation_score
|
|
66
|
+
return 0 if score.nil? # no testable mutants — gate skipped (warning already emitted)
|
|
67
|
+
|
|
68
|
+
score >= threshold ? 0 : 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Canonical machine-readable schema (KTD7). survivors/no_coverage are sorted
|
|
74
|
+
# by (file, line, operator) so output is byte-stable regardless of --jobs
|
|
75
|
+
# worker finish order (R22).
|
|
76
|
+
def json_report
|
|
77
|
+
killed = @agg.killed_count
|
|
78
|
+
survived = @agg.survived_count
|
|
79
|
+
# C8: null (not 0.0) on an empty denominator, matching the nil-vs-0.0
|
|
80
|
+
# discipline in AggregateResult; and the SAME rounding as the human report
|
|
81
|
+
# (one run must not yield two scores by --format).
|
|
82
|
+
score = @agg.mutation_score
|
|
83
|
+
|
|
84
|
+
doc = {
|
|
85
|
+
schema_version: "1.0",
|
|
86
|
+
summary: {
|
|
87
|
+
total: @agg.total, killed: killed, survived: survived,
|
|
88
|
+
no_coverage: @agg.no_coverage_count,
|
|
89
|
+
skipped_invalid: @agg.skipped_invalid_count,
|
|
90
|
+
errored: @agg.errored_count, timeout: @agg.timeout_count,
|
|
91
|
+
score: score
|
|
92
|
+
},
|
|
93
|
+
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
94
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
95
|
+
no_coverage: @agg.results.select(&:no_coverage?).map { |r| no_coverage_json(r) }
|
|
96
|
+
.sort_by { |h| [h[:file], h[:line]] }
|
|
97
|
+
}
|
|
98
|
+
"#{JSON.generate(doc)}\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def survivor_json(result)
|
|
102
|
+
m = result.mutation
|
|
103
|
+
file = result.subject.file
|
|
104
|
+
source = @source_map[file] || File.read(file)
|
|
105
|
+
start_line, original_block, mutated_block, = diff_for(m, source)
|
|
106
|
+
minus = original_block.each_line.map { |l| "-#{l.chomp}" }.join("\n")
|
|
107
|
+
plus = mutated_block.each_line.map { |l| "+#{l.chomp}" }.join("\n")
|
|
108
|
+
{
|
|
109
|
+
subject: result.subject.qualified_name,
|
|
110
|
+
file: file,
|
|
111
|
+
line: start_line,
|
|
112
|
+
operator: m.operator.to_s,
|
|
113
|
+
diff: "--- a/#{file}\n+++ b/#{file}\n@@ -#{start_line} +#{start_line} @@\n#{minus}\n#{plus}\n"
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Builds a line-aligned diff for a mutation whose byte range may span several
|
|
118
|
+
# lines (e.g. statement-removal of a multi-line statement). Returns the
|
|
119
|
+
# mutation's 1-based start line, the full original line-block it touches, the
|
|
120
|
+
# spliced mutated block, and a single-line token label for the header.
|
|
121
|
+
def diff_for(m, source)
|
|
122
|
+
# Byte math (C1): Prism offsets are byte offsets; byteindex/byterindex/
|
|
123
|
+
# byteslice keep line splicing correct for multibyte sources.
|
|
124
|
+
line_begin = m.start_offset.zero? ? 0 : (source.byterindex("\n", m.start_offset - 1) || -1) + 1
|
|
125
|
+
line_end = source.byteindex("\n", m.end_offset) || source.bytesize
|
|
126
|
+
before = source.byteslice(line_begin...m.start_offset)
|
|
127
|
+
after = source.byteslice(m.end_offset...line_end)
|
|
128
|
+
original_block = source.byteslice(line_begin...line_end)
|
|
129
|
+
mutated_block = "#{before}#{m.replacement}#{after}"
|
|
130
|
+
start_line = source.byteslice(0, m.start_offset).count("\n") + 1
|
|
131
|
+
token = source.byteslice(m.start_offset...m.end_offset).gsub(/\s+/, " ").strip
|
|
132
|
+
token = "#{token[0, 47]}..." if token.length > 50
|
|
133
|
+
[start_line, original_block, mutated_block, token]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def no_coverage_json(result)
|
|
137
|
+
m = result.mutation
|
|
138
|
+
file = result.subject.file
|
|
139
|
+
source = @source_map[file] || File.read(file)
|
|
140
|
+
{
|
|
141
|
+
subject: result.subject.qualified_name,
|
|
142
|
+
file: file,
|
|
143
|
+
line: source.byteslice(0, m.start_offset).count("\n") + 1
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def summary(out)
|
|
148
|
+
out.puts "Summary"
|
|
149
|
+
out.puts "-------"
|
|
150
|
+
out.puts format("Total: %-6d Killed: %d", @agg.total, @agg.killed_count)
|
|
151
|
+
out.puts format("Survived: %-6d No coverage: %d", @agg.survived_count, @agg.no_coverage_count)
|
|
152
|
+
out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count,
|
|
153
|
+
@agg.errored_count + @agg.timeout_count)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def score_line(out, err)
|
|
157
|
+
score = @agg.mutation_score
|
|
158
|
+
excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.skipped_invalid_count} skipped, " \
|
|
159
|
+
"#{@agg.errored_count + @agg.timeout_count} errored excluded"
|
|
160
|
+
if score.nil?
|
|
161
|
+
out.puts "Mutation score: N/A (no covered mutants)"
|
|
162
|
+
err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
|
|
163
|
+
else
|
|
164
|
+
out.puts "Mutation score: #{score}% (killed / (killed + survived); #{excluded})"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def survivors(out)
|
|
169
|
+
mutants = @agg.surviving_mutants
|
|
170
|
+
return if mutants.empty?
|
|
171
|
+
|
|
172
|
+
out.puts
|
|
173
|
+
out.puts "Surviving Mutants"
|
|
174
|
+
out.puts "-----------------"
|
|
175
|
+
mutants.group_by { |r| r.subject.file }.sort.each do |file, group|
|
|
176
|
+
out.puts
|
|
177
|
+
out.puts file
|
|
178
|
+
group.sort_by { |r| r.mutation.start_offset }.each { |r| survivor(out, file, r) }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def survivor(out, file, result)
|
|
183
|
+
m = result.mutation
|
|
184
|
+
source = @source_map[file] || File.read(file)
|
|
185
|
+
start_line, original_block, mutated_block, token = diff_for(m, source)
|
|
186
|
+
|
|
187
|
+
out.puts " #{result.subject.qualified_name} (#{File.basename(file)}:#{start_line})"
|
|
188
|
+
out.puts " Operator: #{m.operator} (#{token} -> #{m.replacement})"
|
|
189
|
+
original_block.each_line { |l| out.puts " - #{l.chomp}" }
|
|
190
|
+
mutated_block.each_line { |l| out.puts " + #{l.chomp}" }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def verdict(out, threshold)
|
|
194
|
+
score = @agg.mutation_score
|
|
195
|
+
return if score.nil?
|
|
196
|
+
|
|
197
|
+
if score >= threshold
|
|
198
|
+
out.puts "PASSED: #{score}% >= threshold #{threshold}%"
|
|
199
|
+
else
|
|
200
|
+
out.puts "FAILED: #{score}% < threshold #{threshold}%"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Immutable outcome of running one mutant. Six 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
|
+
#
|
|
12
|
+
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
13
|
+
# validity failure (counted separately by the reporter), error is a runtime
|
|
14
|
+
# crash. Never conflate them via `details` string parsing. `no_coverage` is a
|
|
15
|
+
# pre-fork selection result (M3): excluded from the score denominator.
|
|
16
|
+
#
|
|
17
|
+
# `subject` and `mutation` are nil when the Result is built by Isolation/Runner
|
|
18
|
+
# (which only know the outcome); the orchestrator attaches them afterwards via
|
|
19
|
+
# `result.with(subject:, mutation:)` so the Reporter can render survivor diffs.
|
|
20
|
+
Result = Data.define(:status, :details, :subject, :mutation) do
|
|
21
|
+
def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil)
|
|
22
|
+
def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil)
|
|
23
|
+
def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil)
|
|
24
|
+
def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil)
|
|
25
|
+
def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil)
|
|
26
|
+
def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil)
|
|
27
|
+
|
|
28
|
+
def killed? = status == :killed
|
|
29
|
+
def survived? = status == :survived
|
|
30
|
+
def error? = status == :error
|
|
31
|
+
def timeout? = status == :timeout
|
|
32
|
+
def skipped? = status == :skipped
|
|
33
|
+
def no_coverage? = status == :no_coverage
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Aggregates a flat list of Results into counts, the mutation score, and the
|
|
37
|
+
# surviving-mutant list. The score denominator is killed + survived ONLY
|
|
38
|
+
# (KTD-4): no-coverage, skipped (invalid), errored, and timeout are each
|
|
39
|
+
# excluded and surfaced separately. An empty denominator yields a nil score
|
|
40
|
+
# (rendered "N/A"), never 0.0 — distinguishing "no testable mutants" from
|
|
41
|
+
# "0% killed".
|
|
42
|
+
class AggregateResult
|
|
43
|
+
attr_reader :results
|
|
44
|
+
|
|
45
|
+
def initialize(results)
|
|
46
|
+
@results = results
|
|
47
|
+
@by_status = results.group_by(&:status)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def killed_count = count(:killed)
|
|
51
|
+
def survived_count = count(:survived)
|
|
52
|
+
def no_coverage_count = count(:no_coverage)
|
|
53
|
+
def skipped_invalid_count = count(:skipped)
|
|
54
|
+
def errored_count = count(:error)
|
|
55
|
+
def timeout_count = count(:timeout)
|
|
56
|
+
|
|
57
|
+
# Every generated, classified mutation. NOT the score denominator.
|
|
58
|
+
def total = @results.size
|
|
59
|
+
|
|
60
|
+
# The score denominator (also shown to the reader).
|
|
61
|
+
def covered_count = killed_count + survived_count
|
|
62
|
+
|
|
63
|
+
# killed / (killed + survived) as a rounded percentage, or nil when nothing
|
|
64
|
+
# was testable.
|
|
65
|
+
def mutation_score
|
|
66
|
+
return nil if covered_count.zero?
|
|
67
|
+
|
|
68
|
+
(killed_count.to_f / covered_count * 100).round(1)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def surviving_mutants = @results.select(&:survived?)
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def count(status) = (@by_status[status] || []).size
|
|
76
|
+
end
|
|
77
|
+
end
|