active_mutator 0.1.1 → 0.3.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 +156 -13
- data/lib/active_mutator/accepted_ledger.rb +22 -7
- data/lib/active_mutator/baseline_delta.rb +78 -1
- data/lib/active_mutator/class_shape.rb +47 -0
- data/lib/active_mutator/cli.rb +17 -4
- data/lib/active_mutator/closure_reload.rb +202 -0
- data/lib/active_mutator/config.rb +3 -1
- data/lib/active_mutator/config_file.rb +92 -0
- data/lib/active_mutator/defined_constants.rb +48 -0
- data/lib/active_mutator/edit.rb +8 -2
- data/lib/active_mutator/engine.rb +121 -7
- 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 +128 -0
- data/lib/active_mutator/reporter/terminal.rb +24 -1
- data/lib/active_mutator/result.rb +1 -1
- data/lib/active_mutator/runner.rb +239 -19
- data/lib/active_mutator/scheduler.rb +41 -5
- data/lib/active_mutator/source_location.rb +21 -0
- data/lib/active_mutator/subject.rb +13 -3
- data/lib/active_mutator/subject_finder.rb +89 -9
- 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 +53 -8
- data/lib/active_mutator.rb +10 -0
- metadata +20 -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
|
|
@@ -4,5 +4,7 @@ module ActiveMutator
|
|
|
4
4
|
Config = Data.define(:paths, :since, :subject_filter, :jobs, :format, :requires,
|
|
5
5
|
:timeout_factor, :timeout_floor, :force_baseline, :root,
|
|
6
6
|
:preload_helper, :serial_patterns, :browser_boot_seconds,
|
|
7
|
-
:accept_survivors
|
|
7
|
+
:accept_survivors, :exclude, :max_mutants, :debug_plan,
|
|
8
|
+
:fail_at, :adaptive_timeout, :operators,
|
|
9
|
+
:class_level, :class_level_closure_cap)
|
|
8
10
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
"class_level" => :boolean,
|
|
27
|
+
"class_level_closure_cap" => :positive_integer
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def self.load(root)
|
|
31
|
+
path = File.join(root, FILENAME)
|
|
32
|
+
return {} unless File.exist?(path)
|
|
33
|
+
|
|
34
|
+
data = parse(path)
|
|
35
|
+
return {} if data.nil?
|
|
36
|
+
raise Error, "#{FILENAME}: top level must be a mapping" unless data.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
data.to_h do |key, value|
|
|
39
|
+
validator = KEYS[key]
|
|
40
|
+
raise Error, "#{FILENAME}: unknown config key: #{key}" unless validator
|
|
41
|
+
|
|
42
|
+
[key.to_sym, coerce(key, validator, value)]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.parse(path)
|
|
47
|
+
YAML.safe_load_file(path, aliases: true)
|
|
48
|
+
rescue Psych::Exception => e
|
|
49
|
+
raise Error, "#{FILENAME}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.coerce(key, validator, value)
|
|
53
|
+
case validator
|
|
54
|
+
when :integer
|
|
55
|
+
raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
|
|
56
|
+
value
|
|
57
|
+
when :positive_integer
|
|
58
|
+
raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
|
|
59
|
+
raise Error, "#{FILENAME}: #{key} must be >= 1" unless value >= 1
|
|
60
|
+
value
|
|
61
|
+
when :number
|
|
62
|
+
raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
|
|
63
|
+
value.to_f
|
|
64
|
+
when :score
|
|
65
|
+
raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
|
|
66
|
+
raise Error, "#{FILENAME}: #{key} must be within 0..100" unless (0..100).cover?(value)
|
|
67
|
+
value.to_f
|
|
68
|
+
when :format
|
|
69
|
+
unless FORMATS.include?(value)
|
|
70
|
+
raise Error, "#{FILENAME}: format must be one of #{FORMATS.join(", ")}"
|
|
71
|
+
end
|
|
72
|
+
value.tr("-", "_").to_sym
|
|
73
|
+
when :string_list
|
|
74
|
+
unless value.is_a?(Array) && value.all?(String)
|
|
75
|
+
raise Error, "#{FILENAME}: #{key} must be a list of strings"
|
|
76
|
+
end
|
|
77
|
+
value
|
|
78
|
+
when :boolean
|
|
79
|
+
unless [true, false].include?(value)
|
|
80
|
+
raise Error, "#{FILENAME}: #{key} must be true or false"
|
|
81
|
+
end
|
|
82
|
+
value
|
|
83
|
+
when :preload_helper
|
|
84
|
+
return :none if value == false
|
|
85
|
+
raise Error, "#{FILENAME}: preload_helper must be a path or false" unless value.is_a?(String)
|
|
86
|
+
value
|
|
87
|
+
else
|
|
88
|
+
raise Error, "unhandled validator #{validator}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -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
|
|
@@ -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,27 +24,139 @@ 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|
|
|
38
|
-
@operators.each
|
|
139
|
+
@operators.each do |op|
|
|
140
|
+
edits.concat(op.edits(node))
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
# Fail loud but attributed: a buggy (likely third-party) operator
|
|
143
|
+
# should point at itself, not surface as a bare crash mid-analysis.
|
|
144
|
+
raise Error, "operator #{op.class.name} failed on #{node.class.name}: #{e.message}"
|
|
145
|
+
end
|
|
39
146
|
end
|
|
40
147
|
edits
|
|
41
148
|
end
|
|
42
149
|
|
|
43
150
|
def walk(node, &blk)
|
|
44
151
|
return if node.nil?
|
|
45
|
-
|
|
152
|
+
# Descend into nested DefNodes rather than treating them as separate
|
|
153
|
+
# subjects. Giving a nested def its own subject identity is a trap:
|
|
154
|
+
# every call of the outer method re-executes the nested `def`, which
|
|
155
|
+
# would silently revert a directly-inserted mutant mid-run (phantom
|
|
156
|
+
# survivors). Instead we mutate the nested body as part of the outer
|
|
157
|
+
# def's re-evaled source. (SubjectFinder still emits no subject for
|
|
158
|
+
# nested defs.) walk is called as walk(def_node.body), so the outer
|
|
159
|
+
# DefNode itself never passes through here.
|
|
46
160
|
|
|
47
161
|
yield node
|
|
48
162
|
node.compact_child_nodes.each { |child| walk(child, &blk) }
|
|
@@ -58,7 +172,7 @@ module ActiveMutator
|
|
|
58
172
|
parsed = Prism.parse(mutated)
|
|
59
173
|
return [nil, false] unless parsed.success?
|
|
60
174
|
|
|
61
|
-
new_def =
|
|
175
|
+
new_def = find_node(parsed.value, subject.byte_range.begin, Prism::DefNode)
|
|
62
176
|
return [nil, false] unless new_def
|
|
63
177
|
|
|
64
178
|
[Mutation.new(
|
|
@@ -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
|
)
|