active_mutator 0.1.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/LICENSE +21 -0
- data/README.md +244 -0
- data/exe/active_mutator +4 -0
- data/lib/active_mutator/accepted_ledger.rb +45 -0
- data/lib/active_mutator/analysis.rb +3 -0
- data/lib/active_mutator/atomic_file.rb +15 -0
- data/lib/active_mutator/baseline.rb +125 -0
- data/lib/active_mutator/baseline_delta.rb +57 -0
- data/lib/active_mutator/baseline_hooks.rb +68 -0
- data/lib/active_mutator/cli.rb +53 -0
- data/lib/active_mutator/config.rb +8 -0
- data/lib/active_mutator/coverage_map.rb +55 -0
- data/lib/active_mutator/edit.rb +5 -0
- data/lib/active_mutator/engine.rb +75 -0
- data/lib/active_mutator/fingerprint.rb +21 -0
- data/lib/active_mutator/inserter.rb +18 -0
- data/lib/active_mutator/mutation.rb +11 -0
- data/lib/active_mutator/operators/base.rb +25 -0
- data/lib/active_mutator/operators/call_swap.rb +28 -0
- data/lib/active_mutator/operators/condition_forcing.rb +17 -0
- data/lib/active_mutator/operators/conditional_boundary.rb +16 -0
- data/lib/active_mutator/operators/early_return.rb +16 -0
- data/lib/active_mutator/operators/literal.rb +37 -0
- data/lib/active_mutator/operators/logical_operator.rb +24 -0
- data/lib/active_mutator/operators/negation_removal.rb +11 -0
- data/lib/active_mutator/operators/statement_deletion.rb +15 -0
- data/lib/active_mutator/reporter/json.rb +40 -0
- data/lib/active_mutator/reporter/terminal.rb +50 -0
- data/lib/active_mutator/result.rb +6 -0
- data/lib/active_mutator/runner.rb +143 -0
- data/lib/active_mutator/scheduler.rb +125 -0
- data/lib/active_mutator/since_filter.rb +48 -0
- data/lib/active_mutator/splicer.rb +13 -0
- data/lib/active_mutator/subject.rb +7 -0
- data/lib/active_mutator/subject_finder.rb +56 -0
- data/lib/active_mutator/version.rb +3 -0
- data/lib/active_mutator/work_item.rb +4 -0
- data/lib/active_mutator/worker.rb +64 -0
- data/lib/active_mutator.rb +42 -0
- metadata +132 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
# Cache format v2: primary data is per-example `records`
|
|
5
|
+
# ({example_id => [[abs_path, line], ...]}); the inverted index is derived
|
|
6
|
+
# in memory at load. A missing/old version is simply stale — the cache is
|
|
7
|
+
# disposable, so there is no migration path, only regeneration.
|
|
8
|
+
class CoverageMap
|
|
9
|
+
def self.load(path) = new(JSON.parse(File.read(path)))
|
|
10
|
+
|
|
11
|
+
attr_reader :version, :records
|
|
12
|
+
|
|
13
|
+
def initialize(data)
|
|
14
|
+
@version = data["version"]
|
|
15
|
+
@records = data.fetch("records", {})
|
|
16
|
+
@times = data.fetch("times", {})
|
|
17
|
+
@digests = data.fetch("digests", {})
|
|
18
|
+
@map = build_map
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def examples_for(file, lines)
|
|
22
|
+
lines.flat_map { |line| @map.fetch("#{file}:#{line}", []) }.uniq.sort
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def time_for(example_ids)
|
|
26
|
+
# `|| 0.0`, not fetch-with-default: a key present with nil value must
|
|
27
|
+
# also coerce to zero, or Runner#plan_work explodes with TypeError.
|
|
28
|
+
example_ids.sum { |id| @times[id] || 0.0 }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fresh?(digests) = @version == 2 && @digests == digests
|
|
32
|
+
|
|
33
|
+
def examples_covering_file(abs_path)
|
|
34
|
+
@records.filter_map do |example_id, hits|
|
|
35
|
+
example_id if hits.any? { |(path, _line)| path == abs_path }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def examples_for_spec_file(rel_spec_path)
|
|
40
|
+
@records.keys.select do |example_id|
|
|
41
|
+
example_id.sub(%r{\A\./}, "").start_with?("#{rel_spec_path}[")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_map
|
|
48
|
+
map = Hash.new { |h, k| h[k] = [] }
|
|
49
|
+
@records.each do |example_id, hits|
|
|
50
|
+
hits.each { |(path, line)| map["#{path}:#{line}"] << example_id }
|
|
51
|
+
end
|
|
52
|
+
map
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
class Engine
|
|
3
|
+
def initialize(operators: Operators::Base.all)
|
|
4
|
+
@operators = operators
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def analyze(subject, source: File.read(subject.file))
|
|
8
|
+
result = Prism.parse(source)
|
|
9
|
+
raise Error, "#{subject.file} no longer parses" unless result.success?
|
|
10
|
+
|
|
11
|
+
def_node = find_def(result.value, subject.byte_range.begin)
|
|
12
|
+
raise Error, "subject not found: #{subject.name}" unless def_node
|
|
13
|
+
|
|
14
|
+
invalid = 0
|
|
15
|
+
mutations = collect_edits(def_node).filter_map do |edit|
|
|
16
|
+
mutation, valid = build_mutation(subject, source, edit)
|
|
17
|
+
invalid += 1 unless valid
|
|
18
|
+
mutation
|
|
19
|
+
end
|
|
20
|
+
Analysis.new(mutations: mutations, invalid_count: invalid)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def find_def(node, start_offset)
|
|
26
|
+
return node if node.is_a?(Prism::DefNode) && node.location.start_offset == start_offset
|
|
27
|
+
|
|
28
|
+
node.compact_child_nodes.each do |child|
|
|
29
|
+
found = find_def(child, start_offset)
|
|
30
|
+
return found if found
|
|
31
|
+
end
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def collect_edits(def_node)
|
|
36
|
+
edits = []
|
|
37
|
+
walk(def_node.body) do |node|
|
|
38
|
+
@operators.each { |op| edits.concat(op.edits(node)) }
|
|
39
|
+
end
|
|
40
|
+
edits
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def walk(node, &blk)
|
|
44
|
+
return if node.nil?
|
|
45
|
+
return if node.is_a?(Prism::DefNode) # nested defs are separate subjects
|
|
46
|
+
|
|
47
|
+
yield node
|
|
48
|
+
node.compact_child_nodes.each { |child| walk(child, &blk) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns [mutation_or_nil, valid_boolean].
|
|
52
|
+
# valid=true with nil mutation means "skipped no-op", which is not an error.
|
|
53
|
+
def build_mutation(subject, source, edit)
|
|
54
|
+
original = source.byteslice(edit.range)
|
|
55
|
+
return [nil, true] if edit.replacement == original # no-op guard
|
|
56
|
+
|
|
57
|
+
mutated = Splicer.apply(source, [edit])
|
|
58
|
+
parsed = Prism.parse(mutated)
|
|
59
|
+
return [nil, false] unless parsed.success?
|
|
60
|
+
|
|
61
|
+
new_def = find_def(parsed.value, subject.byte_range.begin)
|
|
62
|
+
return [nil, false] unless new_def
|
|
63
|
+
|
|
64
|
+
[Mutation.new(
|
|
65
|
+
subject: subject,
|
|
66
|
+
edit: edit,
|
|
67
|
+
original_snippet: original,
|
|
68
|
+
line: source.byteslice(0, edit.range.begin).count("\n") + 1,
|
|
69
|
+
mutated_file_source: mutated,
|
|
70
|
+
mutated_def_source: new_def.slice,
|
|
71
|
+
mutated_def_line: new_def.location.start_line
|
|
72
|
+
), true]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# Line-number-independent identity for a mutant, used by the acceptance
|
|
3
|
+
# ledger. `ordinal` disambiguates byte-identical mutants within one subject
|
|
4
|
+
# (e.g. the two `>` in `a > 0 && b > 0`) by source order — without it,
|
|
5
|
+
# accepting one would silently accept both.
|
|
6
|
+
Fingerprint = Data.define(:file, :subject, :description, :original_snippet, :ordinal) do
|
|
7
|
+
def self.for_mutations(mutations, root:)
|
|
8
|
+
counters = Hash.new(0)
|
|
9
|
+
mutations.sort_by { |m| [m.subject.name, m.edit.range.begin] }.to_h do |m|
|
|
10
|
+
key = [m.subject.name, m.description, m.original_snippet]
|
|
11
|
+
ordinal = counters[key]
|
|
12
|
+
counters[key] += 1
|
|
13
|
+
[m, new(file: m.subject.file.delete_prefix("#{root}/"),
|
|
14
|
+
subject: m.subject.name,
|
|
15
|
+
description: m.description,
|
|
16
|
+
original_snippet: m.original_snippet,
|
|
17
|
+
ordinal: ordinal)]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# Redefines the subject's method with its mutated source. `class_eval` of a
|
|
3
|
+
# `def` handles instance methods; a `def self.x` source string defines the
|
|
4
|
+
# singleton method the same way. Top-level subjects eval at main scope.
|
|
5
|
+
class Inserter
|
|
6
|
+
def insert(mutation)
|
|
7
|
+
subject = mutation.subject
|
|
8
|
+
if subject.constant_scope
|
|
9
|
+
Object.const_get(subject.constant_scope)
|
|
10
|
+
.class_eval(mutation.mutated_def_source, subject.file, mutation.mutated_def_line)
|
|
11
|
+
else
|
|
12
|
+
eval(mutation.mutated_def_source, TOPLEVEL_BINDING, # rubocop:disable Security/Eval
|
|
13
|
+
subject.file, mutation.mutated_def_line)
|
|
14
|
+
end
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# One concrete mutant. `line` is the 1-based line of the edit in the
|
|
3
|
+
# ORIGINAL file (used for coverage lookup and reporting).
|
|
4
|
+
Mutation = Data.define(:subject, :edit, :original_snippet, :line,
|
|
5
|
+
:mutated_file_source, :mutated_def_source, :mutated_def_line) do
|
|
6
|
+
def description = edit.description
|
|
7
|
+
|
|
8
|
+
# Original-file lines the edit touches (edit may span lines).
|
|
9
|
+
def lines = line..(line + original_snippet.count("\n"))
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class Base
|
|
4
|
+
REGISTRY = []
|
|
5
|
+
|
|
6
|
+
def self.inherited(klass)
|
|
7
|
+
super
|
|
8
|
+
REGISTRY << klass
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.all = REGISTRY.map(&:new)
|
|
12
|
+
|
|
13
|
+
# Returns [Edit] for this node, or [] when the operator does not apply.
|
|
14
|
+
def edits(node) = []
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def loc_range(loc) = loc.start_offset...loc.end_offset
|
|
19
|
+
|
|
20
|
+
def edit(range, replacement, description)
|
|
21
|
+
Edit.new(range: range, replacement: replacement, description: description)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class CallSwap < Base
|
|
4
|
+
# One-directional where the reverse is usually an equivalent mutant
|
|
5
|
+
# (e.g. each→map when return value is unused).
|
|
6
|
+
MAP = {
|
|
7
|
+
map: "each",
|
|
8
|
+
select: "reject", reject: "select",
|
|
9
|
+
min: "max", max: "min",
|
|
10
|
+
first: "last", last: "first",
|
|
11
|
+
any?: "none?", none?: "any?",
|
|
12
|
+
# Rails-aware pack:
|
|
13
|
+
present?: "blank?", blank?: "present?",
|
|
14
|
+
save: "save!", save!: "save"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def edits(node)
|
|
18
|
+
return [] unless node.is_a?(Prism::CallNode) && node.receiver && node.message_loc
|
|
19
|
+
|
|
20
|
+
replacement = MAP[node.name]
|
|
21
|
+
return [] unless replacement
|
|
22
|
+
|
|
23
|
+
[edit(loc_range(node.message_loc), replacement,
|
|
24
|
+
"replace `.#{node.name}` with `.#{replacement}`")]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class ConditionForcing < Base
|
|
4
|
+
def edits(node)
|
|
5
|
+
predicate =
|
|
6
|
+
case node
|
|
7
|
+
when Prism::IfNode, Prism::UnlessNode then node.predicate
|
|
8
|
+
end
|
|
9
|
+
return [] unless predicate
|
|
10
|
+
|
|
11
|
+
%w[true false].reject { |lit| predicate.slice == lit }.map do |lit|
|
|
12
|
+
edit(loc_range(predicate.location), lit, "force condition to `#{lit}`")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class ConditionalBoundary < Base
|
|
4
|
+
MAP = { :> => ">=", :>= => ">", :< => "<=", :<= => "<" }.freeze
|
|
5
|
+
|
|
6
|
+
def edits(node)
|
|
7
|
+
return [] unless node.is_a?(Prism::CallNode) && MAP.key?(node.name)
|
|
8
|
+
return [] unless node.receiver && node.arguments&.arguments&.size == 1
|
|
9
|
+
|
|
10
|
+
replacement = MAP.fetch(node.name)
|
|
11
|
+
[edit(loc_range(node.message_loc), replacement,
|
|
12
|
+
"replace `#{node.name}` with `#{replacement}`")]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class EarlyReturn < Base
|
|
4
|
+
def edits(node)
|
|
5
|
+
return [] unless node.is_a?(Prism::ReturnNode) && node.arguments
|
|
6
|
+
|
|
7
|
+
value = node.arguments.slice
|
|
8
|
+
out = [edit(loc_range(node.location), value, "unwrap `return`")]
|
|
9
|
+
unless value == "nil"
|
|
10
|
+
out << edit(loc_range(node.location), "return nil", "return nil instead")
|
|
11
|
+
end
|
|
12
|
+
out
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class Literal < Base
|
|
4
|
+
def edits(node)
|
|
5
|
+
case node
|
|
6
|
+
when Prism::IntegerNode then integer_edits(node)
|
|
7
|
+
when Prism::StringNode then string_edits(node)
|
|
8
|
+
when Prism::TrueNode
|
|
9
|
+
[edit(loc_range(node.location), "false", "replace `true` with `false`")]
|
|
10
|
+
when Prism::FalseNode
|
|
11
|
+
[edit(loc_range(node.location), "true", "replace `false` with `true`")]
|
|
12
|
+
else []
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def integer_edits(node)
|
|
19
|
+
[0, node.value + 1].uniq.reject { |v| v == node.value }.map do |v|
|
|
20
|
+
edit(loc_range(node.location), v.to_s, "replace `#{node.value}` with `#{v}`")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def string_edits(node)
|
|
25
|
+
opening = node.opening_loc&.slice
|
|
26
|
+
return [] unless opening # quote-less parts (interpolation)
|
|
27
|
+
return [] if opening.start_with?("<<") # heredocs: v1 limit
|
|
28
|
+
|
|
29
|
+
if node.unescaped.empty?
|
|
30
|
+
[edit(loc_range(node.location), %("active_mutator"), %(replace "" with "active_mutator"))]
|
|
31
|
+
else
|
|
32
|
+
[edit(loc_range(node.location), %(""), %(replace string with ""))]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class LogicalOperator < Base
|
|
4
|
+
def edits(node)
|
|
5
|
+
case node
|
|
6
|
+
when Prism::AndNode then variants(node, "||")
|
|
7
|
+
when Prism::OrNode then variants(node, "&&")
|
|
8
|
+
else []
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def variants(node, swapped)
|
|
15
|
+
[
|
|
16
|
+
edit(loc_range(node.operator_loc), swapped,
|
|
17
|
+
"replace `#{node.operator_loc.slice}` with `#{swapped}`"),
|
|
18
|
+
edit(loc_range(node.location), node.left.slice, "keep only left operand"),
|
|
19
|
+
edit(loc_range(node.location), node.right.slice, "keep only right operand")
|
|
20
|
+
]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class NegationRemoval < Base
|
|
4
|
+
def edits(node)
|
|
5
|
+
return [] unless node.is_a?(Prism::CallNode) && node.name == :! && node.receiver
|
|
6
|
+
|
|
7
|
+
[edit(loc_range(node.location), node.receiver.slice, "remove negation")]
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Operators
|
|
3
|
+
class StatementDeletion < Base
|
|
4
|
+
def edits(node)
|
|
5
|
+
return [] unless node.is_a?(Prism::StatementsNode)
|
|
6
|
+
return [] if node.body.size < 2
|
|
7
|
+
|
|
8
|
+
node.body.map do |stmt|
|
|
9
|
+
edit(loc_range(stmt.location), "",
|
|
10
|
+
"delete `#{stmt.slice.lines.first.strip}`")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
module Reporter
|
|
5
|
+
class Json
|
|
6
|
+
def initialize(out: $stdout)
|
|
7
|
+
@out = out
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def on_result(result); end
|
|
11
|
+
|
|
12
|
+
def summary(results, invalid_count:)
|
|
13
|
+
counts = results.group_by(&:status).transform_values(&:size)
|
|
14
|
+
@out.puts JSON.pretty_generate(
|
|
15
|
+
"score" => Terminal.score(counts),
|
|
16
|
+
"counts" => counts.transform_keys(&:to_s),
|
|
17
|
+
"invalid" => invalid_count,
|
|
18
|
+
"results" => results.map { |r| serialize(r) },
|
|
19
|
+
"exit_reason" => counts.fetch(:survived, 0).positive? ? "unaccepted_survivors" : "clean"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def serialize(result)
|
|
26
|
+
m = result.mutation
|
|
27
|
+
{
|
|
28
|
+
"subject" => m.subject.name,
|
|
29
|
+
"status" => result.status.to_s,
|
|
30
|
+
"description" => m.description,
|
|
31
|
+
"file" => m.subject.file,
|
|
32
|
+
"line" => m.line,
|
|
33
|
+
"original" => m.original_snippet,
|
|
34
|
+
"replacement" => m.edit.replacement,
|
|
35
|
+
"details" => result.details
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Reporter
|
|
3
|
+
class Terminal
|
|
4
|
+
CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A" }.freeze
|
|
5
|
+
|
|
6
|
+
def initialize(out: $stdout)
|
|
7
|
+
@out = out
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def on_result(result)
|
|
11
|
+
@out.print(CHARS.fetch(result.status))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def summary(results, invalid_count:)
|
|
15
|
+
counts = results.group_by(&:status).transform_values(&:size)
|
|
16
|
+
@out.puts "", ""
|
|
17
|
+
CHARS.each_key do |status|
|
|
18
|
+
@out.puts "#{status}: #{counts.fetch(status, 0)}"
|
|
19
|
+
end
|
|
20
|
+
@out.puts "invalid (discarded): #{invalid_count}"
|
|
21
|
+
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
22
|
+
survivors = results.select { |r| r.status == :survived }
|
|
23
|
+
print_survivors(survivors) unless survivors.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.score(counts)
|
|
27
|
+
detected = counts.fetch(:killed, 0) + counts.fetch(:timeout, 0)
|
|
28
|
+
denominator = detected + counts.fetch(:survived, 0)
|
|
29
|
+
return 1.0 if denominator.zero?
|
|
30
|
+
|
|
31
|
+
detected.to_f / denominator
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def score(counts) = self.class.score(counts)
|
|
37
|
+
|
|
38
|
+
def print_survivors(survivors)
|
|
39
|
+
@out.puts "", "Surviving mutants:"
|
|
40
|
+
survivors.each do |result|
|
|
41
|
+
m = result.mutation
|
|
42
|
+
@out.puts "", " #{m.subject.name} (#{m.subject.file}:#{m.line})"
|
|
43
|
+
@out.puts " #{m.description}"
|
|
44
|
+
@out.puts " - #{m.original_snippet}"
|
|
45
|
+
@out.puts " + #{m.edit.replacement}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
class Runner
|
|
3
|
+
def initialize(config, reporter: nil)
|
|
4
|
+
@config = config
|
|
5
|
+
@reporter = reporter || build_reporter
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
ENV["ACTIVE_MUTATOR"] = "1"
|
|
10
|
+
preload!
|
|
11
|
+
preload_spec_helper!
|
|
12
|
+
map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
|
|
13
|
+
subjects = discover_subjects
|
|
14
|
+
analyses = subjects.map { |s| Engine.new.analyze(s) }
|
|
15
|
+
mutations = analyses.flat_map(&:mutations)
|
|
16
|
+
invalid_count = analyses.sum(&:invalid_count)
|
|
17
|
+
|
|
18
|
+
fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
|
|
19
|
+
ledger = AcceptedLedger.load(@config.root)
|
|
20
|
+
warn_stale(ledger, fingerprints.values)
|
|
21
|
+
|
|
22
|
+
items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
23
|
+
pre_results.each { |r| @reporter.on_result(r) }
|
|
24
|
+
scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result))
|
|
25
|
+
results = scheduler.run(items) + pre_results
|
|
26
|
+
|
|
27
|
+
accept_survivors!(ledger, results, fingerprints) if @config.accept_survivors
|
|
28
|
+
|
|
29
|
+
@reporter.summary(results, invalid_count: invalid_count)
|
|
30
|
+
exit_code(results)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns [work_items, pre_results]. Public for unit testing.
|
|
34
|
+
def plan_work(mutations, map, ledger: nil, fingerprints: {})
|
|
35
|
+
items = []
|
|
36
|
+
pre_results = []
|
|
37
|
+
mutations.each do |mutation|
|
|
38
|
+
if ledger&.accepted?(fingerprints[mutation])
|
|
39
|
+
pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
example_ids = map.examples_for(mutation.subject.file, mutation.lines)
|
|
43
|
+
if example_ids.empty?
|
|
44
|
+
pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
|
|
45
|
+
else
|
|
46
|
+
lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
|
|
47
|
+
timeout = map.time_for(example_ids) * @config.timeout_factor + @config.timeout_floor
|
|
48
|
+
timeout += @config.browser_boot_seconds if lane == :serial
|
|
49
|
+
items << WorkItem.new(mutation: mutation, example_ids: example_ids, timeout: timeout, lane: lane)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
[items, pre_results]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def exit_code(results)
|
|
56
|
+
results.any? { |r| r.status == :survived } ? 1 : 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_reporter
|
|
62
|
+
@config.format == :json ? Reporter::Json.new : Reporter::Terminal.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def preload!
|
|
66
|
+
# Workers run the test suite, so the app must boot in the test
|
|
67
|
+
# environment (the development database may not even exist).
|
|
68
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
69
|
+
@config.requires.each { |f| require File.expand_path(f, @config.root) }
|
|
70
|
+
environment = File.join(@config.root, "config", "environment.rb")
|
|
71
|
+
if @config.requires.empty? && File.exist?(environment)
|
|
72
|
+
require environment
|
|
73
|
+
Rails.application.eager_load! if defined?(Rails)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def discover_subjects
|
|
78
|
+
paths = @config.paths.empty? ? default_paths : @config.paths
|
|
79
|
+
subjects = paths
|
|
80
|
+
.flat_map { |p| Dir[File.join(@config.root, p, "**", "*.rb")] }
|
|
81
|
+
.sort.flat_map { |file| SubjectFinder.call(file) }
|
|
82
|
+
subjects = subjects.select { |s| s.name == @config.subject_filter } if @config.subject_filter
|
|
83
|
+
if @config.since
|
|
84
|
+
filter = SinceFilter.new(ref: @config.since, root: @config.root)
|
|
85
|
+
subjects = subjects.select { |s| filter.cover?(s) }
|
|
86
|
+
end
|
|
87
|
+
subjects
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def default_paths
|
|
91
|
+
%w[app lib].select { |p| Dir.exist?(File.join(@config.root, p)) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def serial_example?(example_id)
|
|
95
|
+
path = example_id.sub(%r{\A\./}, "")
|
|
96
|
+
@config.serial_patterns.any? { |pattern| path.start_with?(pattern) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def preload_spec_helper!
|
|
100
|
+
return if @config.preload_helper == :none
|
|
101
|
+
|
|
102
|
+
helper = if @config.preload_helper
|
|
103
|
+
File.expand_path(@config.preload_helper, @config.root)
|
|
104
|
+
else
|
|
105
|
+
%w[spec/rails_helper.rb spec/spec_helper.rb]
|
|
106
|
+
.map { |p| File.join(@config.root, p) }
|
|
107
|
+
.find { |p| File.exist?(p) }
|
|
108
|
+
end
|
|
109
|
+
return unless helper && File.exist?(helper)
|
|
110
|
+
|
|
111
|
+
# Mirror what `bundle exec rspec` provides before a helper loads:
|
|
112
|
+
# rspec-core itself (helpers call RSpec.configure at the top level) and
|
|
113
|
+
# the default path (spec/) on $LOAD_PATH (rails_helper.rb relies on it
|
|
114
|
+
# for its bare `require "spec_helper"`).
|
|
115
|
+
require "rspec/core"
|
|
116
|
+
spec_dir = File.dirname(helper)
|
|
117
|
+
$LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
|
|
118
|
+
require helper
|
|
119
|
+
disarm_simplecov
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A preloaded helper commonly starts SimpleCov. Its at_exit would fire in
|
|
123
|
+
# THIS parent process at the end of the mutation run, clobbering the
|
|
124
|
+
# project's real coverage data — and minimum_coverage would exit(1) for a
|
|
125
|
+
# bogus reason. Neutralize it.
|
|
126
|
+
def disarm_simplecov
|
|
127
|
+
SimpleCov.at_exit {} if defined?(SimpleCov)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def accept_survivors!(ledger, results, fingerprints)
|
|
131
|
+
survivors = results.select { |r| r.status == :survived }.map { |r| fingerprints[r.mutation] }
|
|
132
|
+
return if survivors.empty?
|
|
133
|
+
|
|
134
|
+
ledger.accept!(survivors, fingerprints.values)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def warn_stale(ledger, all_fingerprints)
|
|
138
|
+
ledger.stale_entries(all_fingerprints).each do |entry|
|
|
139
|
+
warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject} — #{entry.description}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|