mutineer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/cli.rb +261 -0
- data/lib/mutineer/config.rb +145 -0
- data/lib/mutineer/coverage_map.rb +222 -0
- data/lib/mutineer/isolation.rb +157 -0
- data/lib/mutineer/minitest_integration.rb +43 -0
- data/lib/mutineer/mutation.rb +21 -0
- data/lib/mutineer/mutator_registry.rb +54 -0
- data/lib/mutineer/mutators/arithmetic.rb +29 -0
- data/lib/mutineer/mutators/base.rb +23 -0
- data/lib/mutineer/mutators/boolean_connector.rb +42 -0
- data/lib/mutineer/mutators/boolean_literal.rb +43 -0
- data/lib/mutineer/mutators/comparison.rb +36 -0
- data/lib/mutineer/mutators/condition_negation.rb +40 -0
- data/lib/mutineer/mutators/literal_mutation.rb +40 -0
- data/lib/mutineer/mutators/return_nil.rb +63 -0
- data/lib/mutineer/mutators/statement_removal.rb +34 -0
- data/lib/mutineer/parser.rb +26 -0
- data/lib/mutineer/project.rb +69 -0
- data/lib/mutineer/reporter.rb +204 -0
- data/lib/mutineer/result.rb +77 -0
- data/lib/mutineer/runner.rb +175 -0
- data/lib/mutineer/subject.rb +19 -0
- data/lib/mutineer/version.rb +5 -0
- data/lib/mutineer/worker_pool.rb +112 -0
- data/lib/mutineer.rb +29 -0
- metadata +104 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "rbconfig"
|
|
8
|
+
|
|
9
|
+
module Mutineer
|
|
10
|
+
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
11
|
+
# the tests that actually exercise its line. Built once (Phase A), then queried
|
|
12
|
+
# per mutant (Phase B via #tests_for). Persisted to .mutineer/coverage.json with
|
|
13
|
+
# a content-based digest that rebuilds the map whenever any tracked file changes.
|
|
14
|
+
#
|
|
15
|
+
# Keys are "file:line" strings (relative to project_root) everywhere — in
|
|
16
|
+
# memory and on disk — so load/save needs no key transformation (KTD4).
|
|
17
|
+
class CoverageMap
|
|
18
|
+
DEFAULT_CAPTURE_TIMEOUT = 120 # seconds, per coverage subprocess (R3)
|
|
19
|
+
|
|
20
|
+
attr_reader :project_root, :failed_test_files, :phase_a_ran
|
|
21
|
+
|
|
22
|
+
def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
|
|
23
|
+
load_paths: ["lib"], project_root: Dir.pwd,
|
|
24
|
+
capture_timeout: DEFAULT_CAPTURE_TIMEOUT)
|
|
25
|
+
@source_paths = Array(source_paths)
|
|
26
|
+
@test_paths = Array(test_paths)
|
|
27
|
+
@cache_dir = cache_dir
|
|
28
|
+
@load_paths = Array(load_paths)
|
|
29
|
+
@project_root = project_root
|
|
30
|
+
@capture_timeout = capture_timeout
|
|
31
|
+
@map = {}
|
|
32
|
+
@failed_test_files = []
|
|
33
|
+
@phase_a_ran = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Phase A entry point: load the cached map when the content digest matches,
|
|
37
|
+
# otherwise rebuild from subprocesses and overwrite the cache.
|
|
38
|
+
def build_or_load
|
|
39
|
+
warn_external_sources
|
|
40
|
+
@digest = compute_digest
|
|
41
|
+
|
|
42
|
+
cached = read_cache
|
|
43
|
+
if cached && cached["digest"] == @digest
|
|
44
|
+
@map = cached["map"] || {}
|
|
45
|
+
@failed_test_files = cached["failed_test_files"] || []
|
|
46
|
+
warn_incomplete unless @failed_test_files.empty?
|
|
47
|
+
return self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
run_phase_a
|
|
51
|
+
save
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Phase B lookup: the test files that cover `file:line`, or [] when none do.
|
|
56
|
+
# ponytail: per-file granularity; upgrade to per-method when throughput
|
|
57
|
+
# warrants (requires Minitest method isolation + finer Coverage tracking).
|
|
58
|
+
def tests_for(file, line)
|
|
59
|
+
@map["#{relativize(file)}:#{line}"] || []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def run_phase_a
|
|
65
|
+
@phase_a_ran = true
|
|
66
|
+
@map = {}
|
|
67
|
+
@failed_test_files = []
|
|
68
|
+
|
|
69
|
+
@test_paths.each do |test_path|
|
|
70
|
+
coverage = capture(test_path)
|
|
71
|
+
next unless coverage
|
|
72
|
+
|
|
73
|
+
record(coverage, test_path)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Spawns a fresh `ruby` reading an inline script from stdin. A fork would
|
|
78
|
+
# miss already-loaded app lines, so Coverage must start in a clean process
|
|
79
|
+
# before any source is loaded (KTD1/KTD2). Returns the parsed Coverage.result
|
|
80
|
+
# hash, or nil when the subprocess failed (logged + skipped per R6).
|
|
81
|
+
def capture(test_path)
|
|
82
|
+
out = +""
|
|
83
|
+
status = nil
|
|
84
|
+
Open3.popen2(RbConfig.ruby, "-") do |stdin, stdout, wait_thr|
|
|
85
|
+
stdin.write(subprocess_script(test_path))
|
|
86
|
+
stdin.close
|
|
87
|
+
reader = Thread.new { out << stdout.read }
|
|
88
|
+
# R3: bound the subprocess with a wall clock — a hanging test file must
|
|
89
|
+
# not wedge the whole run before any per-mutant timeout.
|
|
90
|
+
unless wait_thr.join(@capture_timeout)
|
|
91
|
+
Process.kill(:KILL, wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
92
|
+
reader.kill
|
|
93
|
+
return fail_test(test_path, "timed out after #{@capture_timeout}s")
|
|
94
|
+
end
|
|
95
|
+
reader.join
|
|
96
|
+
status = wait_thr.value
|
|
97
|
+
end
|
|
98
|
+
return fail_test(test_path, "subprocess exited #{status.exitstatus}") unless status.success?
|
|
99
|
+
|
|
100
|
+
JSON.parse(out)
|
|
101
|
+
rescue JSON::ParserError => e
|
|
102
|
+
fail_test(test_path, "invalid coverage output: #{e.message}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fail_test(test_path, reason)
|
|
106
|
+
rel = relativize(test_path)
|
|
107
|
+
@failed_test_files << rel
|
|
108
|
+
warn "[mutineer] coverage skipped for #{rel}: #{reason}"
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def subprocess_script(test_path)
|
|
113
|
+
<<~RUBY
|
|
114
|
+
require "coverage"
|
|
115
|
+
require "json"
|
|
116
|
+
require "stringio"
|
|
117
|
+
require "minitest"
|
|
118
|
+
def Minitest.autorun; end
|
|
119
|
+
Coverage.start(lines: true)
|
|
120
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
121
|
+
#{abs_source_paths.inspect}.each { |f| load f }
|
|
122
|
+
load #{absolute(test_path).inspect}
|
|
123
|
+
_orig = $stdout
|
|
124
|
+
$stdout = StringIO.new
|
|
125
|
+
Minitest.run([])
|
|
126
|
+
$stdout = _orig
|
|
127
|
+
puts Coverage.result.to_json
|
|
128
|
+
RUBY
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Records every source line with a non-zero execution count as covered by
|
|
132
|
+
# this test file. Coverage.result keys are absolute; relativize and drop any
|
|
133
|
+
# path outside the project (stdlib/gem files).
|
|
134
|
+
def record(coverage, test_path)
|
|
135
|
+
rel_test = relativize(test_path)
|
|
136
|
+
coverage.each do |abs_file, data|
|
|
137
|
+
rel = relativize(abs_file)
|
|
138
|
+
next if rel.start_with?("/") # outside project_root — not our source
|
|
139
|
+
|
|
140
|
+
counts = data.is_a?(Array) ? data : data["lines"]
|
|
141
|
+
counts.each_with_index do |count, idx|
|
|
142
|
+
next unless count&.positive?
|
|
143
|
+
|
|
144
|
+
(@map["#{rel}:#{idx + 1}"] ||= []) << rel_test
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# R4: digest each file's ROLE + relative path + content length + content, plus
|
|
150
|
+
# the load_paths. Without role/path/length delimiters the digest collides
|
|
151
|
+
# (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
|
|
152
|
+
# accepting a stale cached map.
|
|
153
|
+
def compute_digest
|
|
154
|
+
d = Digest::SHA256.new
|
|
155
|
+
digest_group(d, "source", @source_paths)
|
|
156
|
+
digest_group(d, "test", @test_paths)
|
|
157
|
+
@load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
|
|
158
|
+
d.hexdigest
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def digest_group(digest, role, paths)
|
|
162
|
+
paths.sort.each do |p|
|
|
163
|
+
content = File.read(absolute(p))
|
|
164
|
+
digest.update(role)
|
|
165
|
+
digest.update("\0")
|
|
166
|
+
digest.update(relativize(absolute(p)))
|
|
167
|
+
digest.update("\0")
|
|
168
|
+
digest.update(content.bytesize.to_s)
|
|
169
|
+
digest.update("\0")
|
|
170
|
+
digest.update(content)
|
|
171
|
+
digest.update("\0")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# R7: a configured source that resolves outside project_root would silently be
|
|
176
|
+
# dropped (its coverage relativizes to an absolute path). Warn instead.
|
|
177
|
+
def warn_external_sources
|
|
178
|
+
@source_paths.each do |p|
|
|
179
|
+
next unless relativize(absolute(p)).start_with?("/")
|
|
180
|
+
|
|
181
|
+
warn "[mutineer] source #{p} is outside project root #{@project_root}; " \
|
|
182
|
+
"its coverage will be ignored"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def cache_path = File.join(@cache_dir, "coverage.json")
|
|
187
|
+
|
|
188
|
+
def read_cache
|
|
189
|
+
return nil unless File.exist?(cache_path)
|
|
190
|
+
|
|
191
|
+
JSON.parse(File.read(cache_path))
|
|
192
|
+
rescue JSON::ParserError
|
|
193
|
+
nil # corrupt cache — rebuild from scratch
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def save
|
|
197
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
198
|
+
data = { "digest" => @digest, "failed_test_files" => @failed_test_files, "map" => @map }
|
|
199
|
+
tmp = "#{cache_path}.tmp"
|
|
200
|
+
File.write(tmp, JSON.generate(data))
|
|
201
|
+
File.rename(tmp, cache_path) # atomic swap
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def warn_incomplete
|
|
205
|
+
warn "[mutineer] cached coverage map may be incomplete; these test files " \
|
|
206
|
+
"failed to contribute: #{@failed_test_files.join(', ')}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def abs_source_paths = @source_paths.map { |p| absolute(p) }
|
|
210
|
+
def abs_load_paths = @load_paths.map { |p| absolute(p) }
|
|
211
|
+
|
|
212
|
+
def relativize(path)
|
|
213
|
+
return path unless path.start_with?("/")
|
|
214
|
+
|
|
215
|
+
path.delete_prefix("#{@project_root}/")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def absolute(path)
|
|
219
|
+
File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require_relative "result"
|
|
5
|
+
require_relative "parser"
|
|
6
|
+
|
|
7
|
+
module Mutineer
|
|
8
|
+
# Fork-based isolation for running one mutant. The block runs in a child
|
|
9
|
+
# process; the parent enforces a wall-clock timeout and decodes the child's
|
|
10
|
+
# exit status into a Result.
|
|
11
|
+
#
|
|
12
|
+
# Exit-status contract (the block's return value, or an explicit exit, is the
|
|
13
|
+
# child's status): 0 => survived, 1 => killed, 2 => error. Timeout is detected
|
|
14
|
+
# by the parent's monitor flag, not by status.signaled? (which is true for ANY
|
|
15
|
+
# signal death, e.g. SIGSEGV — it cannot tell our SIGKILL apart from the OS's).
|
|
16
|
+
#
|
|
17
|
+
# mutineer: the 7a strategy this enables (whole-file `load`) re-executes the
|
|
18
|
+
# entire file — any top-level code runs again. Acceptable for POROs; document
|
|
19
|
+
# if users hit issues with initializers/callbacks. Upgrade path: M5 strategy
|
|
20
|
+
# 7b (class_eval surgical redefinition).
|
|
21
|
+
class Isolation
|
|
22
|
+
DEFAULT_TIMEOUT = 10 # seconds
|
|
23
|
+
|
|
24
|
+
# Runs the block in a forked child. The block's return value (an Integer
|
|
25
|
+
# exit code) or any explicit `exit` is honoured; an unhandled exception
|
|
26
|
+
# becomes exit 2 with the cause written to STDERR.
|
|
27
|
+
def self.run(timeout: DEFAULT_TIMEOUT)
|
|
28
|
+
pid = fork do
|
|
29
|
+
code = 0
|
|
30
|
+
begin
|
|
31
|
+
result = yield
|
|
32
|
+
code = result.is_a?(Integer) ? result : 0
|
|
33
|
+
rescue SystemExit => e
|
|
34
|
+
code = e.status
|
|
35
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
36
|
+
warn "[mutineer-child] #{e.class}: #{e.message}"
|
|
37
|
+
code = 2
|
|
38
|
+
end
|
|
39
|
+
$stderr.flush
|
|
40
|
+
# exit! skips at_exit handlers — critical, since a child forked from
|
|
41
|
+
# inside our own Minitest suite would otherwise re-run the parent's
|
|
42
|
+
# at_exit autorun hook on the way out.
|
|
43
|
+
exit!(code)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Single-threaded deadline poll (R2): we are the ONLY caller of waitpid on
|
|
47
|
+
# this pid, so we never reap-then-kill. We SIGKILL only after WNOHANG shows
|
|
48
|
+
# the child is still alive past the deadline — so the kill can never hit a
|
|
49
|
+
# reaped/recycled pid. Timeout is a parent-side fact (deadline reached), not
|
|
50
|
+
# status.signaled? (which is true for ANY signal death, e.g. SIGSEGV).
|
|
51
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
52
|
+
loop do
|
|
53
|
+
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
54
|
+
return decode(status) if reaped
|
|
55
|
+
|
|
56
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
57
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
58
|
+
Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
59
|
+
return Result.timeout
|
|
60
|
+
end
|
|
61
|
+
sleep 0.005
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Strategy 7a (default): write the whole mutated file and `load` it, which
|
|
66
|
+
# reopens its classes and redefines every method in place. Re-runs file-level
|
|
67
|
+
# side effects. Child-only — mutates the loaded program.
|
|
68
|
+
#
|
|
69
|
+
# The tempfile is created in the ORIGINAL file's directory, not the system
|
|
70
|
+
# temp dir, so any `require_relative` in the mutated source resolves against
|
|
71
|
+
# its real neighbours (e.g. a mutator's `require_relative "base"`). Writing it
|
|
72
|
+
# elsewhere makes those requires resolve to the temp dir and raise LoadError.
|
|
73
|
+
def self.apply_whole_file(mutated, source_file)
|
|
74
|
+
Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(source_file)) do |f|
|
|
75
|
+
f.write(mutated)
|
|
76
|
+
f.flush
|
|
77
|
+
load f.path
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Strategy 7b: extract just the enclosing DefNode, apply the mutation to that
|
|
82
|
+
# snippet, resolve the owner via the namespace path, and class_eval the one
|
|
83
|
+
# method (KTD6). No file-level side effects re-run. Child-only.
|
|
84
|
+
#
|
|
85
|
+
# ponytail: a single owner.class_eval handles BOTH instance and singleton
|
|
86
|
+
# methods — the extracted snippet keeps its own `def self.x` for singletons,
|
|
87
|
+
# so class_eval (self == owner) redefines it correctly. Routing singletons
|
|
88
|
+
# through singleton_class.class_eval (R17) would double-wrap and define on the
|
|
89
|
+
# wrong class.
|
|
90
|
+
def self.apply_surgical(mutation, subject, source)
|
|
91
|
+
loc = subject.def_node.location
|
|
92
|
+
def_start = loc.start_offset
|
|
93
|
+
# Byte slicing (C1): Prism offsets are byte offsets.
|
|
94
|
+
snippet = source.byteslice(def_start...loc.end_offset)
|
|
95
|
+
rel_s = mutation.start_offset - def_start
|
|
96
|
+
rel_e = mutation.end_offset - def_start
|
|
97
|
+
mutated_def = snippet.byteslice(0...rel_s) + mutation.replacement + snippet.byteslice(rel_e..)
|
|
98
|
+
|
|
99
|
+
# Rebuild the FULL namespace nesting textually so unqualified enclosing-
|
|
100
|
+
# namespace constants resolve exactly as a whole-file `load` (7a) would.
|
|
101
|
+
# class_eval(string) would collapse Module.nesting to [owner] and raise
|
|
102
|
+
# NameError on such constants (C2 scope-collapse).
|
|
103
|
+
keywords = nesting_keywords(subject.namespace)
|
|
104
|
+
prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n")
|
|
105
|
+
prefix += "\n" unless prefix.empty?
|
|
106
|
+
wrapped = "#{prefix}#{mutated_def}#{"\nend" * keywords.size}"
|
|
107
|
+
|
|
108
|
+
# A snippet that fails to reparse must NOT silently fall through to running
|
|
109
|
+
# the ORIGINAL method (C2 false-survived). Raise -> the fork block aborts
|
|
110
|
+
# before any test runs -> Result.error, never a bogus `survived`.
|
|
111
|
+
raise "surgical snippet failed to reparse" if Parser.parse_string(wrapped).errors.any?
|
|
112
|
+
|
|
113
|
+
# Preserve original visibility — class/module bodies define methods public,
|
|
114
|
+
# but 7a's `load` would re-apply the file's private/protected (C2).
|
|
115
|
+
owner = subject.namespace.empty? ? Object : Object.const_get(subject.namespace.join("::"))
|
|
116
|
+
target = subject.singleton ? owner.singleton_class : owner
|
|
117
|
+
vis = method_visibility(target, subject.name)
|
|
118
|
+
|
|
119
|
+
# Byte-correct line number; eval at top level so the textual class/module
|
|
120
|
+
# wrappers rebuild Module.nesting. Offset the lineno by the wrapper prefix
|
|
121
|
+
# so the def lands on its real source line.
|
|
122
|
+
def_line = source.byteslice(0, def_start).count("\n") + 1
|
|
123
|
+
eval_line = [def_line - prefix.count("\n"), 1].max
|
|
124
|
+
eval(wrapped, TOPLEVEL_BINDING, subject.file, eval_line) # rubocop:disable Security/Eval
|
|
125
|
+
|
|
126
|
+
target.send(vis, subject.name) if vis && vis != :public
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Resolve each segment of the namespace to its live Module and pick the
|
|
130
|
+
# correct keyword (reopening a class with `module` — or vice versa — raises
|
|
131
|
+
# TypeError), so the textual wrapper matches the real definitions.
|
|
132
|
+
def self.nesting_keywords(namespace)
|
|
133
|
+
mod = Object
|
|
134
|
+
namespace.flat_map { |n| n.split("::") }.map do |name|
|
|
135
|
+
mod = mod.const_get(name)
|
|
136
|
+
[mod.is_a?(Class) ? "class" : "module", name]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.method_visibility(mod, name)
|
|
141
|
+
return :private if mod.private_method_defined?(name)
|
|
142
|
+
return :protected if mod.protected_method_defined?(name)
|
|
143
|
+
return :public if mod.public_method_defined?(name)
|
|
144
|
+
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.decode(status)
|
|
149
|
+
case status.exitstatus
|
|
150
|
+
when 0 then Result.survived
|
|
151
|
+
when 1 then Result.killed
|
|
152
|
+
when 2 then Result.error("child exited with status 2")
|
|
153
|
+
else Result.error("unexpected exit status: #{status.exitstatus.inspect}")
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
# Child-process-only: loads a test file in the current process and runs it
|
|
8
|
+
# programmatically, returning an exit status integer (0 = all passed,
|
|
9
|
+
# 1 = any failure/error).
|
|
10
|
+
#
|
|
11
|
+
# Never call this in the parent — it manipulates global Minitest state
|
|
12
|
+
# (autorun, runnables) that only makes sense in a throwaway forked child.
|
|
13
|
+
#
|
|
14
|
+
# No `rescue` here: Isolation.run's fork block is the single exception
|
|
15
|
+
# boundary (any exception there becomes exit 2). Adding a rescue would create
|
|
16
|
+
# a second exit-2 path and break this method's 0/1 return contract.
|
|
17
|
+
class MinitestIntegration
|
|
18
|
+
# ponytail: tested via runner_test.rb (U6), not in isolation — a direct
|
|
19
|
+
# unit test would require forking and duplicate isolation_test's coverage.
|
|
20
|
+
#
|
|
21
|
+
# `test_files` is one path or an Array of paths (M3 coverage selection passes
|
|
22
|
+
# the covering subset); each is loaded before the single Minitest.run.
|
|
23
|
+
def self.run(test_files)
|
|
24
|
+
# Neutralise autorun so a test file's `require "minitest/autorun"`
|
|
25
|
+
# registers no at_exit hook.
|
|
26
|
+
def Minitest.autorun; end # rubocop:disable Lint/NestedMethodDefinition
|
|
27
|
+
|
|
28
|
+
# Drop runnables inherited from the parent suite (this is the child's
|
|
29
|
+
# private copy — the parent is unaffected) so only the target test runs.
|
|
30
|
+
Minitest::Runnable.reset
|
|
31
|
+
|
|
32
|
+
Array(test_files).each { |f| load f }
|
|
33
|
+
|
|
34
|
+
# Silence the child's test output; the parent only cares about pass/fail.
|
|
35
|
+
orig = $stdout
|
|
36
|
+
$stdout = StringIO.new
|
|
37
|
+
passed = Minitest.run([])
|
|
38
|
+
$stdout = orig
|
|
39
|
+
|
|
40
|
+
passed ? 0 : 1
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
# One atomic byte-range edit. Immutable. One mutation per mutant — never
|
|
7
|
+
# combine. Source is mutated textually, never regenerated from the AST.
|
|
8
|
+
Mutation = Data.define(:start_offset, :end_offset, :replacement, :operator) do
|
|
9
|
+
# Pure: returns a new string, does not mutate `source`. Prism offsets are
|
|
10
|
+
# BYTE offsets, so all slicing is byte-based (byteslice) — char slicing would
|
|
11
|
+
# corrupt any source containing a multibyte char before the mutation point.
|
|
12
|
+
def apply(source)
|
|
13
|
+
source.byteslice(0, start_offset) + replacement + source.byteslice(end_offset..)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Validity rule: a mutation is valid iff the mutated source re-parses clean.
|
|
17
|
+
def valid?(source)
|
|
18
|
+
Parser.parse_string(apply(source)).errors.empty?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mutators/arithmetic"
|
|
4
|
+
require_relative "mutators/comparison"
|
|
5
|
+
require_relative "mutators/boolean_connector"
|
|
6
|
+
require_relative "mutators/boolean_literal"
|
|
7
|
+
require_relative "mutators/statement_removal"
|
|
8
|
+
require_relative "mutators/return_nil"
|
|
9
|
+
require_relative "mutators/literal_mutation"
|
|
10
|
+
require_relative "mutators/condition_negation"
|
|
11
|
+
|
|
12
|
+
module Mutineer
|
|
13
|
+
# Maps operator name -> operator class. DEFAULT_NAMES is the v1 default set
|
|
14
|
+
# (the M4 Tier-1 + statement-removal operators per locked decision #2). The
|
|
15
|
+
# three Tier-2 operators live in ALL but are OFF by default — they only run
|
|
16
|
+
# when named via --operators or `operators:` in .mutineer.yml (KTD8). Keeping
|
|
17
|
+
# DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps the M4 default
|
|
18
|
+
# survivor set unchanged.
|
|
19
|
+
class MutatorRegistry
|
|
20
|
+
ALL = {
|
|
21
|
+
"arithmetic" => Mutators::Arithmetic,
|
|
22
|
+
"comparison" => Mutators::Comparison,
|
|
23
|
+
"boolean_connector" => Mutators::BooleanConnector,
|
|
24
|
+
"boolean_literal" => Mutators::BooleanLiteral,
|
|
25
|
+
"statement_removal" => Mutators::StatementRemoval,
|
|
26
|
+
"return_nil" => Mutators::ReturnNil,
|
|
27
|
+
"literal_mutation" => Mutators::LiteralMutation,
|
|
28
|
+
"condition_negation" => Mutators::ConditionNegation
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
|
|
32
|
+
TIER2_NAMES = %w[return_nil literal_mutation condition_negation].freeze
|
|
33
|
+
|
|
34
|
+
DESCRIPTIONS = {
|
|
35
|
+
"arithmetic" => "+ <-> -, * <-> /, % -> *, ** -> *",
|
|
36
|
+
"comparison" => "< <-> <=, > <-> >=, == <-> !=",
|
|
37
|
+
"boolean_connector" => "&& <-> ||",
|
|
38
|
+
"boolean_literal" => "true <-> false, nil -> true",
|
|
39
|
+
"statement_removal" => "replace a non-final statement with nil",
|
|
40
|
+
"return_nil" => "replace a return / final expression with nil",
|
|
41
|
+
"literal_mutation" => "integer -> 0, 1, n+1; string -> empty",
|
|
42
|
+
"condition_negation" => "wrap if/unless/ternary condition in !( ... )"
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Returns the operator classes for the given names. Unknown names raise
|
|
46
|
+
# ArgumentError immediately (caught at the CLI boundary -> exit 2).
|
|
47
|
+
def self.resolve(names = DEFAULT_NAMES)
|
|
48
|
+
names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.default?(name) = DEFAULT_NAMES.include?(name)
|
|
52
|
+
def self.tier(name) = TIER2_NAMES.include?(name) ? 2 : 1
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Arithmetic operator: +<->-, *<->/, %->*, **->*. One mutation per
|
|
8
|
+
# occurrence, rewriting the operator token (CallNode#message_loc).
|
|
9
|
+
class Arithmetic < Base
|
|
10
|
+
REPLACEMENTS = {
|
|
11
|
+
:+ => "-", :- => "+", :* => "/", :/ => "*", :% => "*", :** => "*"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def visit_call_node(node)
|
|
15
|
+
replacement = REPLACEMENTS[node.name]
|
|
16
|
+
loc = node.message_loc
|
|
17
|
+
if replacement && loc
|
|
18
|
+
@mutations << Mutation.new(
|
|
19
|
+
start_offset: loc.start_offset,
|
|
20
|
+
end_offset: loc.end_offset,
|
|
21
|
+
replacement: replacement,
|
|
22
|
+
operator: :arithmetic
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
super # nested calls (e.g. a + (b * c)) each get their own mutation
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../mutation"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
module Mutators
|
|
8
|
+
# Base Prism visitor for operators. Subclasses override visit_* methods to
|
|
9
|
+
# push Mutation objects onto @mutations. Visiting only def_node.body is the
|
|
10
|
+
# body-only enforcement — the def signature line is never touched.
|
|
11
|
+
#
|
|
12
|
+
# ponytail: one implementor in M1; Base earns its keep at M4 when
|
|
13
|
+
# comparison/boolean operators land and share this contract.
|
|
14
|
+
class Base < Prism::Visitor
|
|
15
|
+
def mutations_for(subject, source)
|
|
16
|
+
@source = source
|
|
17
|
+
@mutations = []
|
|
18
|
+
subject.def_node.body&.accept(self)
|
|
19
|
+
@mutations
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Boolean connector operator: && <-> ||, and <-> or. Replacement is derived
|
|
8
|
+
# from the actual source token (operator_loc.slice) so symbolic and keyword
|
|
9
|
+
# forms each map to their own form — never crossing && to `or`, which would
|
|
10
|
+
# change precedence and surprise the reader (KTD-2).
|
|
11
|
+
#
|
|
12
|
+
# Clean-room: from the spec's operator table, not the mutant gem.
|
|
13
|
+
class BooleanConnector < Base
|
|
14
|
+
REPLACEMENTS = { "&&" => "||", "||" => "&&", "and" => "or", "or" => "and" }.freeze
|
|
15
|
+
|
|
16
|
+
def visit_and_node(node)
|
|
17
|
+
emit(node)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def visit_or_node(node)
|
|
22
|
+
emit(node)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def emit(node)
|
|
29
|
+
loc = node.operator_loc
|
|
30
|
+
replacement = REPLACEMENTS[loc.slice]
|
|
31
|
+
return unless replacement
|
|
32
|
+
|
|
33
|
+
@mutations << Mutation.new(
|
|
34
|
+
start_offset: loc.start_offset,
|
|
35
|
+
end_offset: loc.end_offset,
|
|
36
|
+
replacement: replacement,
|
|
37
|
+
operator: :boolean_connector
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Mutates true/false AND nil literals — "boolean_literal" is the spec's name
|
|
8
|
+
# for the family (§4), so nil is in-scope by design even though it is not
|
|
9
|
+
# strictly a boolean. true<->false, and nil->true (nil->true catches more
|
|
10
|
+
# return-value gaps than nil->false). Rewrites the whole node location;
|
|
11
|
+
# these nodes have no sub-token location.
|
|
12
|
+
#
|
|
13
|
+
# Clean-room: from the spec's operator table, not the mutant gem.
|
|
14
|
+
class BooleanLiteral < Base
|
|
15
|
+
def visit_true_node(node)
|
|
16
|
+
emit(node, "false")
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def visit_false_node(node)
|
|
21
|
+
emit(node, "true")
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def visit_nil_node(node)
|
|
26
|
+
emit(node, "true")
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def emit(node, replacement)
|
|
33
|
+
loc = node.location
|
|
34
|
+
@mutations << Mutation.new(
|
|
35
|
+
start_offset: loc.start_offset,
|
|
36
|
+
end_offset: loc.end_offset,
|
|
37
|
+
replacement: replacement,
|
|
38
|
+
operator: :boolean_literal
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Comparison / boundary operator: <->-<=, >->-=>, ==->!=, etc. The single
|
|
8
|
+
# highest-value Tier-1 family (spec §4) — exposes off-by-one and boundary
|
|
9
|
+
# gaps line coverage never catches. Rewrites the operator token
|
|
10
|
+
# (CallNode#message_loc), one mutation per occurrence.
|
|
11
|
+
#
|
|
12
|
+
# Clean-room: implemented from the spec's operator table and public
|
|
13
|
+
# mutation-testing literature, not the mutant gem.
|
|
14
|
+
class Comparison < Base
|
|
15
|
+
REPLACEMENTS = {
|
|
16
|
+
:< => "<=", :<= => "<", :> => ">=", :>= => ">", :== => "!=", :!= => "=="
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def visit_call_node(node)
|
|
20
|
+
replacement = REPLACEMENTS[node.name]
|
|
21
|
+
loc = node.message_loc
|
|
22
|
+
# receiver guard: reject any unary call accidentally named like an
|
|
23
|
+
# operator; binary comparisons always have a receiver.
|
|
24
|
+
if replacement && loc && node.receiver
|
|
25
|
+
@mutations << Mutation.new(
|
|
26
|
+
start_offset: loc.start_offset,
|
|
27
|
+
end_offset: loc.end_offset,
|
|
28
|
+
replacement: replacement,
|
|
29
|
+
operator: :comparison
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
super # nested comparisons (a >= b && c <= d) each get their own mutation
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|