active_mutator 0.2.0 → 0.4.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 +79 -14
- data/lib/active_mutator/baseline.rb +44 -6
- data/lib/active_mutator/baseline_delta.rb +36 -16
- data/lib/active_mutator/baseline_hooks.rb +15 -6
- data/lib/active_mutator/class_shape.rb +47 -0
- data/lib/active_mutator/cli.rb +22 -2
- data/lib/active_mutator/closure_reload.rb +202 -0
- data/lib/active_mutator/config.rb +4 -2
- data/lib/active_mutator/config_file.rb +15 -5
- data/lib/active_mutator/engine.rb +106 -5
- data/lib/active_mutator/reporter/stryker_json.rb +13 -2
- data/lib/active_mutator/reporter/terminal.rb +13 -1
- data/lib/active_mutator/result.rb +1 -1
- data/lib/active_mutator/runner.rb +136 -14
- data/lib/active_mutator/subject.rb +6 -2
- data/lib/active_mutator/subject_finder.rb +45 -4
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/worker.rb +50 -8
- data/lib/active_mutator.rb +2 -0
- metadata +12 -5
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# Fork-side insertion for class-body mutants. A def mutant can be
|
|
3
|
+
# class_eval'd over the live constant; class-level code cannot (re-running
|
|
4
|
+
# `validates` ADDS a validator, it doesn't replace one). So: remove the
|
|
5
|
+
# constant and re-eval the whole mutated file. Anything already attached to
|
|
6
|
+
# the OLD object — classes that include the module, subclasses, extend
|
|
7
|
+
# sites — would go stale, so they are removed and re-evaled too (pristine
|
|
8
|
+
# sources), dependency-first. The fork dies after the run; nothing is
|
|
9
|
+
# restored.
|
|
10
|
+
#
|
|
11
|
+
# Every guard failure raises Skip; the Worker reports the mutant as
|
|
12
|
+
# `skipped` with the reason. Skipping is honest: a mutant we cannot insert
|
|
13
|
+
# faithfully must not be counted as survived OR killed.
|
|
14
|
+
#
|
|
15
|
+
# Known limitations (inherent to remove-and-reload; accepted trade-offs):
|
|
16
|
+
# - References that hold the target BY VALUE rather than by ancestry are
|
|
17
|
+
# not discovered and keep pointing at the pre-remove_const object:
|
|
18
|
+
# aliases (`ALIAS = MyClass`), arrays/registries the target was pushed
|
|
19
|
+
# into, memoized instances, class variables captured at load time.
|
|
20
|
+
# - `refine`-based modules are anonymous and do not appear in normal
|
|
21
|
+
# `ancestors`, so refinements of the target are not discovered/reloaded.
|
|
22
|
+
# - Re-evaling the target AND each attacher re-runs their class-body side
|
|
23
|
+
# effects. Non-idempotent load-time effects (self-registration into a
|
|
24
|
+
# global registry, DescendantsTracker-style hooks) therefore run twice; a
|
|
25
|
+
# spec asserting such a count can see it doubled (false kill) or masked
|
|
26
|
+
# (false survival).
|
|
27
|
+
# - The re-eval order pins the target first (every attacher depends on it)
|
|
28
|
+
# then sorts the rest by instance-ancestor depth. An `extend`
|
|
29
|
+
# relationship BETWEEN two non-target attachers can still re-eval out of
|
|
30
|
+
# order (rare); a full topological sort is deliberately not attempted.
|
|
31
|
+
class ClosureReload
|
|
32
|
+
Skip = Class.new(StandardError)
|
|
33
|
+
|
|
34
|
+
# The MUTATED target source could not be re-evaled — the mutation broke the
|
|
35
|
+
# class so it no longer loads. Every covering spec loads the class, so the
|
|
36
|
+
# suite would fail: the Worker maps this to a kill, not an error.
|
|
37
|
+
MutantLoadError = Class.new(StandardError)
|
|
38
|
+
|
|
39
|
+
DEFAULT_CAP = 10
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Assigned by Runner from config before scheduling; forks inherit it.
|
|
43
|
+
attr_writer :cap
|
|
44
|
+
|
|
45
|
+
def cap = @cap || DEFAULT_CAP
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(subject, mutated_source)
|
|
49
|
+
@subject = subject
|
|
50
|
+
@mutated_source = mutated_source
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call(cap: self.class.cap)
|
|
54
|
+
target = resolve_target
|
|
55
|
+
closure = compute_closure(target)
|
|
56
|
+
if closure.size > cap
|
|
57
|
+
raise Skip, "reload closure (#{closure.size} constants) exceeds cap (#{cap})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Re-eval must load dependencies before dependents. Every closure member
|
|
61
|
+
# carries the target directly (single-pass discovery invariant), so the
|
|
62
|
+
# target must be re-eval'd first — pin it to the front. `ancestors.size`
|
|
63
|
+
# can't do this alone: an `extend` puts the target in the extender's
|
|
64
|
+
# SINGLETON ancestry, so a module that extends the target has an instance
|
|
65
|
+
# ancestry SMALLER than the target's and would sort before it (NameError
|
|
66
|
+
# on re-eval). For the remaining attachers, ascending instance-ancestor
|
|
67
|
+
# depth is a valid order among include/subclass relationships (a
|
|
68
|
+
# superclass before its subclass, an included module before its
|
|
69
|
+
# includer); depth is captured while the constants are still live, since
|
|
70
|
+
# it can't be read after remove_const.
|
|
71
|
+
rest = closure.reject { |m| m.equal?(target) }
|
|
72
|
+
ordered = [target, *rest.sort_by { |m| m.ancestors.size }]
|
|
73
|
+
sources = ordered.map { |mod| [mod.name, source_for(mod)] }
|
|
74
|
+
sources.each { |name, _| remove_constant(name) }
|
|
75
|
+
sources.each_with_index do |(_, (file, src)), idx|
|
|
76
|
+
eval(src, TOPLEVEL_BINDING, file, 1) # rubocop:disable Security/Eval
|
|
77
|
+
rescue ScriptError, StandardError => e
|
|
78
|
+
# idx.zero? is the MUTATED target, which by construction depends on
|
|
79
|
+
# nothing else in the closure (every other member carries the target,
|
|
80
|
+
# not vice versa). Its source failing to load is therefore the
|
|
81
|
+
# mutation's own doing — a kill, not a tool error.
|
|
82
|
+
#
|
|
83
|
+
# A PRISTINE dependent (idx > 0) failing means the ancestry-depth order
|
|
84
|
+
# couldn't satisfy a cross-attacher reference (see the ordering note
|
|
85
|
+
# above): we can't faithfully reinstate the closure, so this is an
|
|
86
|
+
# honest Skip, not a survived/killed verdict and not a bare error.
|
|
87
|
+
raise MutantLoadError, e.message if idx.zero?
|
|
88
|
+
|
|
89
|
+
raise Skip, "reload re-eval failed (#{e.message}); closure could not be reinstated in dependency order"
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def resolve_target
|
|
97
|
+
scope = @subject.constant_scope
|
|
98
|
+
target = begin
|
|
99
|
+
Object.const_get(scope)
|
|
100
|
+
rescue NameError
|
|
101
|
+
raise Skip, "constant #{scope} not loaded"
|
|
102
|
+
end
|
|
103
|
+
file, = Object.const_source_location(scope)
|
|
104
|
+
unless file && File.identical?(file, @subject.file)
|
|
105
|
+
raise Skip, "#{scope} defined at #{file || "?"}, not #{@subject.file} (reopened constant)"
|
|
106
|
+
end
|
|
107
|
+
target
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Single discovery pass. Ruby's `ancestors` is transitive, so one scan for
|
|
111
|
+
# everything carrying the target already yields every transitive attacher:
|
|
112
|
+
# an attacher-of-an-attacher (a subclass of an includer, an includer of an
|
|
113
|
+
# includer) carries the target directly too. No BFS/dedup is needed — the
|
|
114
|
+
# re-eval order is imposed later by pinning the target first and sorting the
|
|
115
|
+
# rest by ancestry depth (see #call), not by discovery order and not by a
|
|
116
|
+
# full topological sort (deliberately not attempted; see class docstring).
|
|
117
|
+
def compute_closure(target)
|
|
118
|
+
[target, *attachers(target)]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Everything stale after removing `mod`: includers and subclasses carry
|
|
122
|
+
# it in `ancestors`; extend-sites carry it in their singleton class's
|
|
123
|
+
# ancestors, so singleton classes map back through attached_object. `.uniq`
|
|
124
|
+
# collapses a member found both ways (a class that both includes and
|
|
125
|
+
# extends the target).
|
|
126
|
+
def attachers(mod)
|
|
127
|
+
ObjectSpace.each_object(Module).filter_map do |m|
|
|
128
|
+
next if m.equal?(mod)
|
|
129
|
+
next unless carries?(m, mod)
|
|
130
|
+
|
|
131
|
+
if m.singleton_class?
|
|
132
|
+
m = m.attached_object
|
|
133
|
+
unless m.is_a?(Module)
|
|
134
|
+
raise Skip, "an object instance is extended with #{mod.name || mod.inspect}; not reloadable"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
m
|
|
139
|
+
end.uniq
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ObjectSpace hands back every Module in the VM, including ones we cannot
|
|
143
|
+
# introspect — e.g. a module whose #ancestors is overridden to raise. A
|
|
144
|
+
# module we cannot read is not a reloadable dependency of the target, so
|
|
145
|
+
# treat it as unrelated rather than aborting the whole scan. Trade-off: a
|
|
146
|
+
# genuine attacher whose #ancestors raises would be silently dropped
|
|
147
|
+
# (acceptable — pathological).
|
|
148
|
+
#
|
|
149
|
+
# The rescue is deliberately broader than StandardError: #ancestors on a
|
|
150
|
+
# foreign object can raise Exception-level errors that are NOT StandardError
|
|
151
|
+
# — the canonical case is an expired RSpec verifying double
|
|
152
|
+
# (ExpiredTestDoubleError < MockExpectationError < Exception) left in
|
|
153
|
+
# ObjectSpace by another spec. Control-flow errors (a signal such as an
|
|
154
|
+
# interrupt, or an explicit exit) are re-raised so a run stays
|
|
155
|
+
# interruptible; everything else means "not a dependency, skip it".
|
|
156
|
+
def carries?(mod, target)
|
|
157
|
+
mod.ancestors.include?(target)
|
|
158
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
159
|
+
raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
160
|
+
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def source_for(mod)
|
|
165
|
+
name = mod.name
|
|
166
|
+
raise Skip, "anonymous #{mod.is_a?(Class) ? "class" : "module"} in reload closure" unless name
|
|
167
|
+
|
|
168
|
+
return [@subject.file, @mutated_source] if name == @subject.constant_scope
|
|
169
|
+
|
|
170
|
+
file, = Object.const_source_location(name)
|
|
171
|
+
raise Skip, "#{name}: no source file (native or dynamically defined)" unless file && File.exist?(file)
|
|
172
|
+
|
|
173
|
+
src = File.read(file)
|
|
174
|
+
unless single_constant_file?(src)
|
|
175
|
+
raise Skip, "#{name}: #{file} defines multiple top-level constants; not reloadable"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
[file, src]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Same Zeitwerk-shape rule the SubjectFinder gate applies to the target
|
|
182
|
+
# file: re-evaling a multi-constant file would re-run macros on constants
|
|
183
|
+
# that were NOT removed (accumulation bugs). Shared with SubjectFinder.
|
|
184
|
+
def single_constant_file?(source)
|
|
185
|
+
result = Prism.parse(source)
|
|
186
|
+
return false unless result.success?
|
|
187
|
+
|
|
188
|
+
ClassShape.single_top_level_constant?(result.value)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def remove_constant(name)
|
|
192
|
+
parts = name.split("::")
|
|
193
|
+
leaf = parts.pop
|
|
194
|
+
parent = parts.empty? ? Object : Object.const_get(parts.join("::"))
|
|
195
|
+
parent.send(:remove_const, leaf)
|
|
196
|
+
rescue NameError
|
|
197
|
+
# Either a parent namespace earlier in the closure was already removed
|
|
198
|
+
# (taking this nested constant with it), or the leaf is already gone.
|
|
199
|
+
# Nothing to remove — the re-eval pass reinstates it from its own file.
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -3,7 +3,9 @@ require "etc"
|
|
|
3
3
|
module ActiveMutator
|
|
4
4
|
Config = Data.define(:paths, :since, :subject_filter, :jobs, :format, :requires,
|
|
5
5
|
:timeout_factor, :timeout_floor, :force_baseline, :root,
|
|
6
|
-
:preload_helper, :serial_patterns, :
|
|
6
|
+
:preload_helper, :serial_patterns, :spec_paths,
|
|
7
|
+
:browser_boot_seconds,
|
|
7
8
|
:accept_survivors, :exclude, :max_mutants, :debug_plan,
|
|
8
|
-
:fail_at, :adaptive_timeout, :
|
|
9
|
+
:fail_at, :adaptive_timeout, :operators,
|
|
10
|
+
:class_level, :class_level_closure_cap)
|
|
9
11
|
end
|
|
@@ -19,15 +19,15 @@ module ActiveMutator
|
|
|
19
19
|
"fail_at" => :score,
|
|
20
20
|
"exclude" => :string_list,
|
|
21
21
|
"serial_patterns" => :string_list,
|
|
22
|
+
"spec_paths" => :nonempty_string_list,
|
|
22
23
|
"requires" => :string_list,
|
|
23
24
|
"operators" => :string_list,
|
|
24
25
|
"preload_helper" => :preload_helper,
|
|
25
|
-
"adaptive_timeout" => :boolean
|
|
26
|
+
"adaptive_timeout" => :boolean,
|
|
27
|
+
"class_level" => :boolean,
|
|
28
|
+
"class_level_closure_cap" => :positive_integer
|
|
26
29
|
}.freeze
|
|
27
30
|
|
|
28
|
-
# YAML keys that don't match their Config member name.
|
|
29
|
-
RENAMES = { "operators" => :operator_paths }.freeze
|
|
30
|
-
|
|
31
31
|
def self.load(root)
|
|
32
32
|
path = File.join(root, FILENAME)
|
|
33
33
|
return {} unless File.exist?(path)
|
|
@@ -40,7 +40,7 @@ module ActiveMutator
|
|
|
40
40
|
validator = KEYS[key]
|
|
41
41
|
raise Error, "#{FILENAME}: unknown config key: #{key}" unless validator
|
|
42
42
|
|
|
43
|
-
[
|
|
43
|
+
[key.to_sym, coerce(key, validator, value)]
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -55,6 +55,10 @@ module ActiveMutator
|
|
|
55
55
|
when :integer
|
|
56
56
|
raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
|
|
57
57
|
value
|
|
58
|
+
when :positive_integer
|
|
59
|
+
raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
|
|
60
|
+
raise Error, "#{FILENAME}: #{key} must be >= 1" unless value >= 1
|
|
61
|
+
value
|
|
58
62
|
when :number
|
|
59
63
|
raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
|
|
60
64
|
value.to_f
|
|
@@ -72,6 +76,12 @@ module ActiveMutator
|
|
|
72
76
|
raise Error, "#{FILENAME}: #{key} must be a list of strings"
|
|
73
77
|
end
|
|
74
78
|
value
|
|
79
|
+
when :nonempty_string_list
|
|
80
|
+
unless value.is_a?(Array) && value.all?(String)
|
|
81
|
+
raise Error, "#{FILENAME}: #{key} must be a list of strings"
|
|
82
|
+
end
|
|
83
|
+
raise Error, "#{FILENAME}: #{key} must not be empty" if value.empty?
|
|
84
|
+
value
|
|
75
85
|
when :boolean
|
|
76
86
|
unless [true, false].include?(value)
|
|
77
87
|
raise Error, "#{FILENAME}: #{key} must be true or false"
|
|
@@ -8,7 +8,9 @@ module ActiveMutator
|
|
|
8
8
|
result = Prism.parse(source)
|
|
9
9
|
raise Error, "#{subject.file} no longer parses" unless result.success?
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
return analyze_class_body(subject, source, result) if subject.class_body?
|
|
12
|
+
|
|
13
|
+
def_node = find_node(result.value, subject.byte_range.begin, Prism::DefNode)
|
|
12
14
|
raise Error, "subject not found: #{subject.name}" unless def_node
|
|
13
15
|
|
|
14
16
|
invalid = 0
|
|
@@ -22,16 +24,115 @@ module ActiveMutator
|
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
+
def analyze_class_body(subject, source, result)
|
|
28
|
+
class_node = find_node(result.value, subject.byte_range.begin, Prism::ClassNode, Prism::ModuleNode)
|
|
29
|
+
raise Error, "subject not found: #{subject.name}" unless class_node
|
|
30
|
+
|
|
31
|
+
invalid = 0
|
|
32
|
+
mutations = collect_class_body_edits(class_node).filter_map do |edit|
|
|
33
|
+
mutation, valid = build_class_body_mutation(subject, source, edit)
|
|
34
|
+
invalid += 1 unless valid
|
|
35
|
+
mutation
|
|
36
|
+
end
|
|
37
|
+
Analysis.new(mutations: mutations, invalid_count: invalid)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Depth-first search for the node of one of `types` whose byte range starts
|
|
41
|
+
# at `start_offset`. Def-level and class-body subject location share this;
|
|
42
|
+
# only the node type(s) differ (DefNode vs Class/ModuleNode).
|
|
43
|
+
def find_node(node, start_offset, *types)
|
|
44
|
+
return node if types.any? { |t| node.is_a?(t) } && node.location.start_offset == start_offset
|
|
27
45
|
|
|
28
46
|
node.compact_child_nodes.each do |child|
|
|
29
|
-
found =
|
|
47
|
+
found = find_node(child, start_offset, *types)
|
|
30
48
|
return found if found
|
|
31
49
|
end
|
|
32
50
|
nil
|
|
33
51
|
end
|
|
34
52
|
|
|
53
|
+
# Class-level code only: defs, nested class/modules and `class << self`
|
|
54
|
+
# bodies are owned by other subjects. Lambdas (scope bodies, if: procs) ARE
|
|
55
|
+
# descended, as are the ActiveSupport::Concern DSL blocks (`included`,
|
|
56
|
+
# `prepended`, `class_methods`) whose bodies re-run as class-level code in
|
|
57
|
+
# the includer (issue #31). Every OTHER block (association extensions,
|
|
58
|
+
# custom DSLs that run in an unknown context) stays pruned — mutating those
|
|
59
|
+
# risks false survivors. Edits that would delete a whole owned statement
|
|
60
|
+
# (StatementDeletion sees the enclosing StatementsNode) are discarded.
|
|
61
|
+
# Owned ranges are collected recursively while walking, not just from the
|
|
62
|
+
# class body's direct children: a def can nest inside class-level control
|
|
63
|
+
# flow (`if`/`unless`/`begin`), and deleting it there is equally out of
|
|
64
|
+
# scope.
|
|
65
|
+
def collect_class_body_edits(class_node)
|
|
66
|
+
owned = []
|
|
67
|
+
edits = []
|
|
68
|
+
class_walk(class_node.body, owned) do |node|
|
|
69
|
+
@operators.each do |op|
|
|
70
|
+
edits.concat(op.edits(node))
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
raise Error, "operator #{op.class.name} failed on #{node.class.name}: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
edits.reject { |e| owned.include?(e.range) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def owned_statement?(node) = ClassShape.owned_by_other_subject?(node)
|
|
79
|
+
|
|
80
|
+
# ActiveSupport::Concern DSL calls whose block body re-runs as class-level
|
|
81
|
+
# code in the includer, so it is in scope for class-body mutation.
|
|
82
|
+
CONCERN_BLOCK_CALLS = %i[included prepended class_methods].freeze
|
|
83
|
+
|
|
84
|
+
def concern_dsl_block?(node)
|
|
85
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
86
|
+
CONCERN_BLOCK_CALLS.include?(node.name) && node.block.is_a?(Prism::BlockNode)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# No nil guard needed (unlike #walk): the entry node is the class body's
|
|
90
|
+
# StatementsNode, guaranteed present for a class-body subject, and
|
|
91
|
+
# compact_child_nodes never yields nil.
|
|
92
|
+
def class_walk(node, owned, &blk)
|
|
93
|
+
if owned_statement?(node)
|
|
94
|
+
owned << (node.location.start_offset...node.location.end_offset)
|
|
95
|
+
elsif node.is_a?(Prism::BlockNode)
|
|
96
|
+
# Pruned: a block's run-time context is unknown (see collect comment).
|
|
97
|
+
elsif concern_dsl_block?(node)
|
|
98
|
+
# Inside a concern block the statements have no subject of their own, so
|
|
99
|
+
# mutate everything (including nested def bodies) exactly like the
|
|
100
|
+
# def-level #walk — do NOT recurse via class_walk (it would prune the
|
|
101
|
+
# block) and do NOT mark the interior defs owned. The concern call node
|
|
102
|
+
# itself is not yielded: the whole-block deletion edit already comes
|
|
103
|
+
# from the enclosing StatementsNode, and no operator targets a bare
|
|
104
|
+
# receiverless call.
|
|
105
|
+
walk(node.block.body, &blk)
|
|
106
|
+
else
|
|
107
|
+
yield node
|
|
108
|
+
node.compact_child_nodes.each { |child| class_walk(child, owned, &blk) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The mutant is the whole file. The def-shaped fields are filled with the
|
|
113
|
+
# file source so the Mutation shape stays uniform; Worker routes
|
|
114
|
+
# class-body mutants through ClosureReload (whole-file re-eval), never
|
|
115
|
+
# through Inserter's class_eval.
|
|
116
|
+
def build_class_body_mutation(subject, source, edit)
|
|
117
|
+
original = source.byteslice(edit.range)
|
|
118
|
+
return [nil, true] if edit.replacement == original # no-op guard
|
|
119
|
+
|
|
120
|
+
mutated = Splicer.apply(source, [edit])
|
|
121
|
+
parsed = Prism.parse(mutated)
|
|
122
|
+
return [nil, false] unless parsed.success?
|
|
123
|
+
return [nil, false] unless find_node(parsed.value, subject.byte_range.begin, Prism::ClassNode, Prism::ModuleNode)
|
|
124
|
+
|
|
125
|
+
[Mutation.new(
|
|
126
|
+
subject: subject,
|
|
127
|
+
edit: edit,
|
|
128
|
+
original_snippet: original,
|
|
129
|
+
line: source.byteslice(0, edit.range.begin).count("\n") + 1,
|
|
130
|
+
mutated_file_source: mutated,
|
|
131
|
+
mutated_def_source: mutated,
|
|
132
|
+
mutated_def_line: 1
|
|
133
|
+
), true]
|
|
134
|
+
end
|
|
135
|
+
|
|
35
136
|
def collect_edits(def_node)
|
|
36
137
|
edits = []
|
|
37
138
|
walk(def_node.body) do |node|
|
|
@@ -71,7 +172,7 @@ module ActiveMutator
|
|
|
71
172
|
parsed = Prism.parse(mutated)
|
|
72
173
|
return [nil, false] unless parsed.success?
|
|
73
174
|
|
|
74
|
-
new_def =
|
|
175
|
+
new_def = find_node(parsed.value, subject.byte_range.begin, Prism::DefNode)
|
|
75
176
|
return [nil, false] unless new_def
|
|
76
177
|
|
|
77
178
|
[Mutation.new(
|
|
@@ -13,7 +13,8 @@ module ActiveMutator
|
|
|
13
13
|
class StrykerJson
|
|
14
14
|
SCHEMA_URL = "https://git.io/mutation-testing-schema"
|
|
15
15
|
STATUS = { killed: "Killed", survived: "Survived", timeout: "Timeout",
|
|
16
|
-
error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored"
|
|
16
|
+
error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored",
|
|
17
|
+
skipped: "Ignored" }.freeze
|
|
17
18
|
ACCEPTED_REASON = "Accepted as equivalent in #{AcceptedLedger::FILENAME}".freeze
|
|
18
19
|
REPORT_PATH = File.join(".active_mutator", "mutation-report.json")
|
|
19
20
|
|
|
@@ -87,7 +88,17 @@ module ActiveMutator
|
|
|
87
88
|
def covered_by(result)
|
|
88
89
|
return nil unless @coverage_map
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
subject = result.mutation.subject
|
|
92
|
+
# Class-body lines execute at load time, so per-line coverage never
|
|
93
|
+
# attributes examples to them (see Runner#examples_for_mutation). Mirror
|
|
94
|
+
# the scheduling substitution — every example that loaded the file — so
|
|
95
|
+
# the viewer shows real test linkage instead of an empty coveredBy.
|
|
96
|
+
if subject.class_body?
|
|
97
|
+
examples = @coverage_map.examples_covering_file(subject.file)
|
|
98
|
+
return examples.empty? ? nil : examples.sort
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@coverage_map.examples_for(subject.file, result.mutation.lines)
|
|
91
102
|
end
|
|
92
103
|
|
|
93
104
|
def referenced_examples(results)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
module Reporter
|
|
3
3
|
class Terminal
|
|
4
|
-
CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A"
|
|
4
|
+
CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A",
|
|
5
|
+
skipped: "-" }.freeze
|
|
5
6
|
|
|
6
7
|
def initialize(out: $stdout)
|
|
7
8
|
@out = out
|
|
@@ -21,6 +22,8 @@ module ActiveMutator
|
|
|
21
22
|
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
22
23
|
survivors = results.select { |r| r.status == :survived }
|
|
23
24
|
print_survivors(survivors) unless survivors.empty?
|
|
25
|
+
skipped = results.select { |r| r.status == :skipped }
|
|
26
|
+
print_skipped(skipped) unless skipped.empty?
|
|
24
27
|
stats = OperatorStats.call(results)
|
|
25
28
|
noisy = stats.select { |_, s| s["survived"].positive? }
|
|
26
29
|
print_operator_stats(noisy) unless noisy.empty?
|
|
@@ -54,6 +57,15 @@ module ActiveMutator
|
|
|
54
57
|
@out.puts " #{m.description}"
|
|
55
58
|
@out.puts " - #{m.original_snippet}"
|
|
56
59
|
@out.puts " + #{m.edit.replacement}"
|
|
60
|
+
@out.puts " (#{result.details})" if result.details
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def print_skipped(skipped)
|
|
65
|
+
@out.puts "", "Skipped mutants (not counted in the score):"
|
|
66
|
+
skipped.each do |result|
|
|
67
|
+
m = result.mutation
|
|
68
|
+
@out.puts " #{m.subject.name} (#{m.subject.file}:#{m.line}): #{result.details}"
|
|
57
69
|
end
|
|
58
70
|
end
|
|
59
71
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
|
-
# status: :killed | :survived | :timeout | :error | :uncovered | :accepted
|
|
2
|
+
# status: :killed | :survived | :timeout | :error | :uncovered | :accepted | :skipped
|
|
3
3
|
Result = Data.define(:mutation, :status, :details) do
|
|
4
4
|
def detected? = %i[killed timeout].include?(status)
|
|
5
5
|
end
|