active_mutator 0.1.0 → 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 +4 -4
- data/README.md +160 -63
- data/lib/active_mutator/accepted_ledger.rb +23 -8
- data/lib/active_mutator/atomic_file.rb +1 -1
- data/lib/active_mutator/baseline.rb +13 -4
- data/lib/active_mutator/baseline_delta.rb +67 -1
- data/lib/active_mutator/baseline_hooks.rb +2 -2
- data/lib/active_mutator/cli.rb +16 -4
- data/lib/active_mutator/config.rb +2 -1
- data/lib/active_mutator/config_file.rb +89 -0
- data/lib/active_mutator/coverage_map.rb +1 -1
- data/lib/active_mutator/defined_constants.rb +48 -0
- data/lib/active_mutator/edit.rb +8 -2
- data/lib/active_mutator/engine.rb +15 -2
- data/lib/active_mutator/fingerprint.rb +1 -1
- data/lib/active_mutator/inserter.rb +6 -3
- data/lib/active_mutator/operators/base.rb +2 -1
- data/lib/active_mutator/operators/call_swap.rb +16 -0
- data/lib/active_mutator/operators/literal.rb +14 -2
- data/lib/active_mutator/reporter/github.rb +36 -0
- data/lib/active_mutator/reporter/json.rb +1 -0
- data/lib/active_mutator/reporter/operator_stats.rb +20 -0
- data/lib/active_mutator/reporter/stryker_json.rb +117 -0
- data/lib/active_mutator/reporter/terminal.rb +11 -0
- data/lib/active_mutator/runner.rb +121 -17
- data/lib/active_mutator/scheduler.rb +64 -7
- data/lib/active_mutator/source_location.rb +21 -0
- data/lib/active_mutator/subject.rb +7 -1
- data/lib/active_mutator/subject_finder.rb +46 -7
- data/lib/active_mutator/subject_matcher.rb +23 -0
- data/lib/active_mutator/timeout_calibrator.rb +75 -0
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/work_item.rb +8 -1
- data/lib/active_mutator/worker.rb +5 -2
- data/lib/active_mutator.rb +8 -0
- metadata +13 -5
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
# Project config file, layered UNDER CLI flags: CLI.parse seeds its option
|
|
5
|
+
# defaults from this before OptionParser runs, so any flag given on the
|
|
6
|
+
# command line wins. Strict on unknown keys and types — a typo silently
|
|
7
|
+
# ignored would be a config that silently doesn't apply.
|
|
8
|
+
class ConfigFile
|
|
9
|
+
FILENAME = ".active_mutator.yml"
|
|
10
|
+
|
|
11
|
+
FORMATS = %w[terminal json stryker-json github].freeze
|
|
12
|
+
|
|
13
|
+
KEYS = {
|
|
14
|
+
"jobs" => :integer,
|
|
15
|
+
"format" => :format,
|
|
16
|
+
"timeout_factor" => :number,
|
|
17
|
+
"timeout_floor" => :number,
|
|
18
|
+
"browser_boot_seconds" => :number,
|
|
19
|
+
"fail_at" => :score,
|
|
20
|
+
"exclude" => :string_list,
|
|
21
|
+
"serial_patterns" => :string_list,
|
|
22
|
+
"requires" => :string_list,
|
|
23
|
+
"operators" => :string_list,
|
|
24
|
+
"preload_helper" => :preload_helper,
|
|
25
|
+
"adaptive_timeout" => :boolean
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# YAML keys that don't match their Config member name.
|
|
29
|
+
RENAMES = { "operators" => :operator_paths }.freeze
|
|
30
|
+
|
|
31
|
+
def self.load(root)
|
|
32
|
+
path = File.join(root, FILENAME)
|
|
33
|
+
return {} unless File.exist?(path)
|
|
34
|
+
|
|
35
|
+
data = parse(path)
|
|
36
|
+
return {} if data.nil?
|
|
37
|
+
raise Error, "#{FILENAME}: top level must be a mapping" unless data.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
data.to_h do |key, value|
|
|
40
|
+
validator = KEYS[key]
|
|
41
|
+
raise Error, "#{FILENAME}: unknown config key: #{key}" unless validator
|
|
42
|
+
|
|
43
|
+
[RENAMES.fetch(key, key.to_sym), coerce(key, validator, value)]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.parse(path)
|
|
48
|
+
YAML.safe_load_file(path, aliases: true)
|
|
49
|
+
rescue Psych::Exception => e
|
|
50
|
+
raise Error, "#{FILENAME}: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.coerce(key, validator, value)
|
|
54
|
+
case validator
|
|
55
|
+
when :integer
|
|
56
|
+
raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
|
|
57
|
+
value
|
|
58
|
+
when :number
|
|
59
|
+
raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
|
|
60
|
+
value.to_f
|
|
61
|
+
when :score
|
|
62
|
+
raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
|
|
63
|
+
raise Error, "#{FILENAME}: #{key} must be within 0..100" unless (0..100).cover?(value)
|
|
64
|
+
value.to_f
|
|
65
|
+
when :format
|
|
66
|
+
unless FORMATS.include?(value)
|
|
67
|
+
raise Error, "#{FILENAME}: format must be one of #{FORMATS.join(", ")}"
|
|
68
|
+
end
|
|
69
|
+
value.tr("-", "_").to_sym
|
|
70
|
+
when :string_list
|
|
71
|
+
unless value.is_a?(Array) && value.all?(String)
|
|
72
|
+
raise Error, "#{FILENAME}: #{key} must be a list of strings"
|
|
73
|
+
end
|
|
74
|
+
value
|
|
75
|
+
when :boolean
|
|
76
|
+
unless [true, false].include?(value)
|
|
77
|
+
raise Error, "#{FILENAME}: #{key} must be true or false"
|
|
78
|
+
end
|
|
79
|
+
value
|
|
80
|
+
when :preload_helper
|
|
81
|
+
return :none if value == false
|
|
82
|
+
raise Error, "#{FILENAME}: preload_helper must be a path or false" unless value.is_a?(String)
|
|
83
|
+
value
|
|
84
|
+
else
|
|
85
|
+
raise Error, "unhandled validator #{validator}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -3,7 +3,7 @@ require "json"
|
|
|
3
3
|
module ActiveMutator
|
|
4
4
|
# Cache format v2: primary data is per-example `records`
|
|
5
5
|
# ({example_id => [[abs_path, line], ...]}); the inverted index is derived
|
|
6
|
-
# in memory at load. A missing/old version is simply stale
|
|
6
|
+
# in memory at load. A missing/old version is simply stale: the cache is
|
|
7
7
|
# disposable, so there is no migration path, only regeneration.
|
|
8
8
|
class CoverageMap
|
|
9
9
|
def self.load(path) = new(JSON.parse(File.read(path)))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
# Deepest fully qualified names of classes/modules a source file defines
|
|
5
|
+
# ("Billing::Invoice"). Two shorthands are deliberately never emitted,
|
|
6
|
+
# because either would let a single common token match half of any real
|
|
7
|
+
# spec suite and trip BaselineDelta's full-run fallback on every edit:
|
|
8
|
+
# - bare leaves ("Config" for MyApp::Config)
|
|
9
|
+
# - pure namespace wrappers ("MyApp" for `module MyApp; class Config`):
|
|
10
|
+
# every file in a namespaced app reopens the top module, and every
|
|
11
|
+
# spec mentions it.
|
|
12
|
+
# A wrapper is a node whose non-empty direct body contains ONLY nested
|
|
13
|
+
# class/module definitions. A module with its own defs/macros/constants is
|
|
14
|
+
# a real edit target and IS emitted; so is an empty or def-less leaf class
|
|
15
|
+
# (macro-only ActiveRecord models).
|
|
16
|
+
#
|
|
17
|
+
# Guard is errors.any?, not warnings: Prism produces a complete AST for
|
|
18
|
+
# warnings-only input (`if a = 2`), and those definitions are real.
|
|
19
|
+
module DefinedConstants
|
|
20
|
+
def self.in_source(source)
|
|
21
|
+
result = Prism.parse(source)
|
|
22
|
+
return [] if result.errors.any?
|
|
23
|
+
|
|
24
|
+
names = []
|
|
25
|
+
walk(result.value, [], names)
|
|
26
|
+
names.uniq
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The walk intentionally descends into block bodies (unlike SubjectFinder,
|
|
30
|
+
# which skips them): over-inclusion is the safe direction for spec-file
|
|
31
|
+
# matching, so a constant defined inside a block is still emitted.
|
|
32
|
+
def self.walk(node, scope, names)
|
|
33
|
+
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
34
|
+
scope = scope + [node.constant_path.slice]
|
|
35
|
+
names << scope.join("::") unless namespace_wrapper?(node)
|
|
36
|
+
end
|
|
37
|
+
node.compact_child_nodes.each { |child| walk(child, scope, names) }
|
|
38
|
+
end
|
|
39
|
+
private_class_method :walk
|
|
40
|
+
|
|
41
|
+
def self.namespace_wrapper?(node)
|
|
42
|
+
statements = node.body.is_a?(Prism::StatementsNode) ? node.body.body : []
|
|
43
|
+
statements.any? &&
|
|
44
|
+
statements.all? { |s| s.is_a?(Prism::ClassNode) || s.is_a?(Prism::ModuleNode) }
|
|
45
|
+
end
|
|
46
|
+
private_class_method :namespace_wrapper?
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/active_mutator/edit.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# A single mutation as a text edit: replace `range` (exclusive byte Range)
|
|
3
|
-
# in the original source with `replacement`.
|
|
4
|
-
|
|
3
|
+
# in the original source with `replacement`. `operator` is the producing
|
|
4
|
+
# operator's demodulized class name ("CallSwap"), "Unknown" outside the
|
|
5
|
+
# operator pipeline.
|
|
6
|
+
Edit = Data.define(:range, :replacement, :description, :operator) do
|
|
7
|
+
def initialize(range:, replacement:, description:, operator: "Unknown")
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
5
11
|
end
|
|
@@ -35,14 +35,27 @@ module ActiveMutator
|
|
|
35
35
|
def collect_edits(def_node)
|
|
36
36
|
edits = []
|
|
37
37
|
walk(def_node.body) do |node|
|
|
38
|
-
@operators.each
|
|
38
|
+
@operators.each do |op|
|
|
39
|
+
edits.concat(op.edits(node))
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
# Fail loud but attributed: a buggy (likely third-party) operator
|
|
42
|
+
# should point at itself, not surface as a bare crash mid-analysis.
|
|
43
|
+
raise Error, "operator #{op.class.name} failed on #{node.class.name}: #{e.message}"
|
|
44
|
+
end
|
|
39
45
|
end
|
|
40
46
|
edits
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def walk(node, &blk)
|
|
44
50
|
return if node.nil?
|
|
45
|
-
|
|
51
|
+
# Descend into nested DefNodes rather than treating them as separate
|
|
52
|
+
# subjects. Giving a nested def its own subject identity is a trap:
|
|
53
|
+
# every call of the outer method re-executes the nested `def`, which
|
|
54
|
+
# would silently revert a directly-inserted mutant mid-run (phantom
|
|
55
|
+
# survivors). Instead we mutate the nested body as part of the outer
|
|
56
|
+
# def's re-evaled source. (SubjectFinder still emits no subject for
|
|
57
|
+
# nested defs.) walk is called as walk(def_node.body), so the outer
|
|
58
|
+
# DefNode itself never passes through here.
|
|
46
59
|
|
|
47
60
|
yield node
|
|
48
61
|
node.compact_child_nodes.each { |child| walk(child, &blk) }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# Line-number-independent identity for a mutant, used by the acceptance
|
|
3
3
|
# ledger. `ordinal` disambiguates byte-identical mutants within one subject
|
|
4
|
-
# (e.g. the two `>` in `a > 0 && b > 0`) by source order
|
|
4
|
+
# (e.g. the two `>` in `a > 0 && b > 0`) by source order. Without it,
|
|
5
5
|
# accepting one would silently accept both.
|
|
6
6
|
Fingerprint = Data.define(:file, :subject, :description, :original_snippet, :ordinal) do
|
|
7
7
|
def self.for_mutations(mutations, root:)
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# Redefines the subject's method with its mutated source. `class_eval` of a
|
|
3
3
|
# `def` handles instance methods; a `def self.x` source string defines the
|
|
4
|
-
# singleton method the same way.
|
|
4
|
+
# singleton method the same way. An sclass subject's source is a plain
|
|
5
|
+
# `def foo` that must land on the constant's singleton class, so we route it
|
|
6
|
+
# through `.singleton_class.class_eval`. Top-level subjects eval at main scope.
|
|
5
7
|
class Inserter
|
|
6
8
|
def insert(mutation)
|
|
7
9
|
subject = mutation.subject
|
|
8
10
|
if subject.constant_scope
|
|
9
|
-
Object.const_get(subject.constant_scope)
|
|
10
|
-
|
|
11
|
+
target = Object.const_get(subject.constant_scope)
|
|
12
|
+
target = target.singleton_class if subject.sclass
|
|
13
|
+
target.class_eval(mutation.mutated_def_source, subject.file, mutation.mutated_def_line)
|
|
11
14
|
else
|
|
12
15
|
eval(mutation.mutated_def_source, TOPLEVEL_BINDING, # rubocop:disable Security/Eval
|
|
13
16
|
subject.file, mutation.mutated_def_line)
|
|
@@ -18,7 +18,8 @@ module ActiveMutator
|
|
|
18
18
|
def loc_range(loc) = loc.start_offset...loc.end_offset
|
|
19
19
|
|
|
20
20
|
def edit(range, replacement, description)
|
|
21
|
-
Edit.new(range: range, replacement: replacement, description: description
|
|
21
|
+
Edit.new(range: range, replacement: replacement, description: description,
|
|
22
|
+
operator: self.class.name.split("::").last)
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
end
|
|
@@ -9,6 +9,22 @@ module ActiveMutator
|
|
|
9
9
|
min: "max", max: "min",
|
|
10
10
|
first: "last", last: "first",
|
|
11
11
|
any?: "none?", none?: "any?",
|
|
12
|
+
# all? is one-way: any? already pairs with none?, so all?→any? adds a
|
|
13
|
+
# distinct mutant without a redundant reverse edge.
|
|
14
|
+
all?: "any?",
|
|
15
|
+
take: "drop", drop: "take",
|
|
16
|
+
min_by: "max_by", max_by: "min_by",
|
|
17
|
+
# sort→reverse is one-way by design: reverse already has a strong
|
|
18
|
+
# forward mutant here, and reverse→sort would double-map `reverse`
|
|
19
|
+
# against nothing useful (reverse has no MAP entry to preserve).
|
|
20
|
+
sort: "reverse",
|
|
21
|
+
# detect/find→first is one-way: first ignores the retained block, so
|
|
22
|
+
# the mutant usually differs (equivalent only when element 0 already
|
|
23
|
+
# satisfies the predicate). No reverse edge: `first` is taken by
|
|
24
|
+
# first→last above.
|
|
25
|
+
detect: "first", find: "first",
|
|
26
|
+
# Evaluated and rejected: sum (initial-arg arity mismatch),
|
|
27
|
+
# find_index (no safe partner — rindex is Array-only).
|
|
12
28
|
# Rails-aware pack:
|
|
13
29
|
present?: "blank?", blank?: "present?",
|
|
14
30
|
save: "save!", save!: "save"
|
|
@@ -23,8 +23,8 @@ module ActiveMutator
|
|
|
23
23
|
|
|
24
24
|
def string_edits(node)
|
|
25
25
|
opening = node.opening_loc&.slice
|
|
26
|
-
return [] unless opening
|
|
27
|
-
return
|
|
26
|
+
return [] unless opening # quote-less parts (interpolation)
|
|
27
|
+
return heredoc_edits(node) if opening.start_with?("<<")
|
|
28
28
|
|
|
29
29
|
if node.unescaped.empty?
|
|
30
30
|
[edit(loc_range(node.location), %("active_mutator"), %(replace "" with "active_mutator"))]
|
|
@@ -32,6 +32,18 @@ module ActiveMutator
|
|
|
32
32
|
[edit(loc_range(node.location), %(""), %(replace string with ""))]
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
# The node span covers the `<<~X` opening token; splicing there breaks
|
|
37
|
+
# the source. Mutate the body content range instead: nonempty body →
|
|
38
|
+
# empty heredoc (opening line directly followed by the terminator).
|
|
39
|
+
# The guard is on the DEDENTED VALUE (unescaped), not content_loc: a
|
|
40
|
+
# squiggly body that dedents to "" would only lose whitespace bytes —
|
|
41
|
+
# an equivalent mutant — so it is skipped even though content is nonempty.
|
|
42
|
+
def heredoc_edits(node)
|
|
43
|
+
return [] if node.unescaped.empty?
|
|
44
|
+
|
|
45
|
+
[edit(loc_range(node.content_loc), "", "empty heredoc body")]
|
|
46
|
+
end
|
|
35
47
|
end
|
|
36
48
|
end
|
|
37
49
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Reporter
|
|
3
|
+
# GitHub Actions workflow-command projection (issue #19): one ::warning
|
|
4
|
+
# annotation per surviving mutant, inlined on the PR diff. Everything
|
|
5
|
+
# else mirrors the terminal reporter so CI logs stay readable.
|
|
6
|
+
class Github
|
|
7
|
+
def initialize(root:, out: $stdout)
|
|
8
|
+
@root = root
|
|
9
|
+
@terminal = Terminal.new(out: out)
|
|
10
|
+
@out = out
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def on_result(result) = @terminal.on_result(result)
|
|
14
|
+
|
|
15
|
+
def summary(results, invalid_count:)
|
|
16
|
+
@terminal.summary(results, invalid_count: invalid_count)
|
|
17
|
+
results.select { |r| r.status == :survived }.each { |r| annotate(r) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def annotate(result)
|
|
23
|
+
m = result.mutation
|
|
24
|
+
file = m.subject.file.delete_prefix(@root.chomp("/") + "/")
|
|
25
|
+
message = "#{m.subject.name}: #{m.description} | - #{m.original_snippet} | + #{m.edit.replacement}"
|
|
26
|
+
@out.puts "::warning file=#{file},line=#{m.line},title=Surviving mutant::#{encode(message)}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# GitHub workflow commands terminate at a raw newline; percent-encode
|
|
30
|
+
# per https://github.com/actions/toolkit runner rules.
|
|
31
|
+
def encode(message)
|
|
32
|
+
message.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -15,6 +15,7 @@ module ActiveMutator
|
|
|
15
15
|
"score" => Terminal.score(counts),
|
|
16
16
|
"counts" => counts.transform_keys(&:to_s),
|
|
17
17
|
"invalid" => invalid_count,
|
|
18
|
+
"operators" => OperatorStats.call(results),
|
|
18
19
|
"results" => results.map { |r| serialize(r) },
|
|
19
20
|
"exit_reason" => counts.fetch(:survived, 0).positive? ? "unaccepted_survivors" : "clean"
|
|
20
21
|
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Reporter
|
|
3
|
+
# Per-operator noise signal (issue #20): equivalent_rate =
|
|
4
|
+
# survived / (killed + survived). Every :survived result is covered by
|
|
5
|
+
# construction (uncovered mutants never reach the scheduler), so this is
|
|
6
|
+
# the covered-survivor rate. It deliberately conflates true equivalents
|
|
7
|
+
# with weak assertions — it is an aggregate signal, not a score.
|
|
8
|
+
module OperatorStats
|
|
9
|
+
def self.call(results)
|
|
10
|
+
results.group_by { |r| r.mutation.edit.operator }.to_h do |operator, group|
|
|
11
|
+
killed = group.count { |r| r.status == :killed }
|
|
12
|
+
survived = group.count { |r| r.status == :survived }
|
|
13
|
+
denominator = killed + survived
|
|
14
|
+
rate = denominator.zero? ? 0.0 : (survived.to_f / denominator).round(3)
|
|
15
|
+
[operator, { "killed" => killed, "survived" => survived, "equivalent_rate" => rate }]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
module Reporter
|
|
5
|
+
# mutation-testing-report-schema v2 (the Stryker ecosystem format).
|
|
6
|
+
# Load the written file in https://microsoft.github.io/mutation-testing-elements/
|
|
7
|
+
# for the interactive per-file mutant viewer.
|
|
8
|
+
#
|
|
9
|
+
# Schema constraints honored here: 1-based positive line/column, integer
|
|
10
|
+
# thresholds, tool-specific data only under config.active_mutator.
|
|
11
|
+
# Invalid mutants are discarded before results exist, so they appear as a
|
|
12
|
+
# count in the extras, never as CompileError mutants.
|
|
13
|
+
class StrykerJson
|
|
14
|
+
SCHEMA_URL = "https://git.io/mutation-testing-schema"
|
|
15
|
+
STATUS = { killed: "Killed", survived: "Survived", timeout: "Timeout",
|
|
16
|
+
error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored" }.freeze
|
|
17
|
+
ACCEPTED_REASON = "Accepted as equivalent in #{AcceptedLedger::FILENAME}".freeze
|
|
18
|
+
REPORT_PATH = File.join(".active_mutator", "mutation-report.json")
|
|
19
|
+
|
|
20
|
+
# Injected by Runner once the baseline map exists; nil in unit tests.
|
|
21
|
+
attr_writer :coverage_map
|
|
22
|
+
|
|
23
|
+
def initialize(root:, out: $stdout)
|
|
24
|
+
@root = root
|
|
25
|
+
@out = out
|
|
26
|
+
@coverage_map = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def on_result(result)
|
|
30
|
+
@out.print(Terminal::CHARS.fetch(result.status))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def summary(results, invalid_count:)
|
|
34
|
+
report = build_report(results, invalid_count)
|
|
35
|
+
path = File.join(@root, REPORT_PATH)
|
|
36
|
+
AtomicFile.write(path, JSON.pretty_generate(report))
|
|
37
|
+
@out.puts "", "", "Stryker report written to #{REPORT_PATH}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_report(results, invalid_count)
|
|
43
|
+
mutants_by_file = results.group_by { |r| r.mutation.subject.file }
|
|
44
|
+
report = {
|
|
45
|
+
"$schema" => SCHEMA_URL,
|
|
46
|
+
"schemaVersion" => "2",
|
|
47
|
+
"thresholds" => { "high" => 80, "low" => 60 },
|
|
48
|
+
"projectRoot" => @root,
|
|
49
|
+
"config" => { "active_mutator" => { "invalid_discarded" => invalid_count,
|
|
50
|
+
"version" => VERSION } },
|
|
51
|
+
"files" => mutants_by_file.to_h { |file, rs| [relative(file), file_entry(file, rs)] }
|
|
52
|
+
}
|
|
53
|
+
tests = referenced_examples(results)
|
|
54
|
+
report["testFiles"] = test_files(tests) unless tests.empty?
|
|
55
|
+
report
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def file_entry(file, results)
|
|
59
|
+
source = File.read(file)
|
|
60
|
+
{ "language" => "ruby", "source" => source,
|
|
61
|
+
"mutants" => results.map { |r| mutant(r, source) } }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def mutant(result, source)
|
|
65
|
+
loc = SourceLocation.locate(source, result.mutation.edit.range)
|
|
66
|
+
entry = {
|
|
67
|
+
"id" => next_id,
|
|
68
|
+
"mutatorName" => result.mutation.edit.operator,
|
|
69
|
+
"location" => { "start" => stringify(loc[:start]), "end" => stringify(loc[:end]) },
|
|
70
|
+
"status" => STATUS.fetch(result.status),
|
|
71
|
+
"replacement" => result.mutation.edit.replacement,
|
|
72
|
+
"description" => result.mutation.description
|
|
73
|
+
}
|
|
74
|
+
reason = status_reason(result)
|
|
75
|
+
entry["statusReason"] = reason if reason
|
|
76
|
+
covered = covered_by(result)
|
|
77
|
+
entry["coveredBy"] = covered if covered
|
|
78
|
+
entry
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def status_reason(result)
|
|
82
|
+
return ACCEPTED_REASON if result.status == :accepted
|
|
83
|
+
|
|
84
|
+
result.details&.to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def covered_by(result)
|
|
88
|
+
return nil unless @coverage_map
|
|
89
|
+
|
|
90
|
+
@coverage_map.examples_for(result.mutation.subject.file, result.mutation.lines)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def referenced_examples(results)
|
|
94
|
+
results.flat_map { |r| covered_by(r) || [] }.uniq.sort
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Group example ids by spec path (the id up to the trailing "[...]")
|
|
98
|
+
# so the viewer's test panel resolves coveredBy references.
|
|
99
|
+
def test_files(example_ids)
|
|
100
|
+
example_ids
|
|
101
|
+
.group_by { |id| id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "") }
|
|
102
|
+
.transform_values do |ids|
|
|
103
|
+
{ "tests" => ids.map { |id| { "id" => id, "name" => id } } }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def next_id
|
|
108
|
+
@next_id = (@next_id || -1) + 1
|
|
109
|
+
@next_id.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stringify(position) = { "line" => position[:line], "column" => position[:column] }
|
|
113
|
+
|
|
114
|
+
def relative(file) = file.delete_prefix(@root.chomp("/") + "/")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -21,6 +21,9 @@ module ActiveMutator
|
|
|
21
21
|
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
22
22
|
survivors = results.select { |r| r.status == :survived }
|
|
23
23
|
print_survivors(survivors) unless survivors.empty?
|
|
24
|
+
stats = OperatorStats.call(results)
|
|
25
|
+
noisy = stats.select { |_, s| s["survived"].positive? }
|
|
26
|
+
print_operator_stats(noisy) unless noisy.empty?
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def self.score(counts)
|
|
@@ -35,6 +38,14 @@ module ActiveMutator
|
|
|
35
38
|
|
|
36
39
|
def score(counts) = self.class.score(counts)
|
|
37
40
|
|
|
41
|
+
def print_operator_stats(stats)
|
|
42
|
+
@out.puts "", "Equivalent-rate by operator (survived / (killed + survived)):"
|
|
43
|
+
stats.sort_by { |_, s| -s["equivalent_rate"] }.each do |operator, s|
|
|
44
|
+
@out.puts format(" %-24s %5.1f%% (%d survived / %d killed)",
|
|
45
|
+
operator, s["equivalent_rate"] * 100, s["survived"], s["killed"])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
38
49
|
def print_survivors(survivors)
|
|
39
50
|
@out.puts "", "Surviving mutants:"
|
|
40
51
|
survivors.each do |result|
|