mutineer 0.6.2 → 0.8.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/CHANGELOG.md +59 -0
- data/README.md +63 -2
- data/lib/mutineer/baseline.rb +86 -0
- data/lib/mutineer/changed_lines.rb +29 -12
- data/lib/mutineer/cli.rb +137 -9
- data/lib/mutineer/config.rb +30 -2
- data/lib/mutineer/coverage_map.rb +135 -11
- data/lib/mutineer/isolation.rb +89 -40
- data/lib/mutineer/minitest_integration.rb +13 -9
- data/lib/mutineer/mutant_id.rb +66 -0
- data/lib/mutineer/mutation.rb +12 -6
- data/lib/mutineer/mutator_registry.rb +21 -3
- data/lib/mutineer/mutators/arithmetic.rb +8 -2
- data/lib/mutineer/mutators/base.rb +11 -3
- data/lib/mutineer/mutators/boolean_connector.rb +15 -5
- data/lib/mutineer/mutators/boolean_literal.rb +19 -6
- data/lib/mutineer/mutators/comparison.rb +7 -8
- data/lib/mutineer/mutators/condition_negation.rb +14 -5
- data/lib/mutineer/mutators/literal_mutation.rb +15 -4
- data/lib/mutineer/mutators/return_nil.rb +22 -7
- data/lib/mutineer/mutators/statement_removal.rb +6 -9
- data/lib/mutineer/pairing.rb +86 -0
- data/lib/mutineer/parser.rb +17 -7
- data/lib/mutineer/project.rb +73 -2
- data/lib/mutineer/reporter.rb +172 -8
- data/lib/mutineer/result.rb +128 -32
- data/lib/mutineer/runner.rb +101 -9
- data/lib/mutineer/subject.rb +10 -6
- data/lib/mutineer/test_runners/minitest.rb +5 -4
- data/lib/mutineer/test_runners/rspec.rb +10 -17
- data/lib/mutineer/test_runners.rb +9 -2
- data/lib/mutineer/version.rb +2 -1
- data/lib/mutineer/worker_pool.rb +34 -21
- data/lib/mutineer.rb +3 -0
- metadata +18 -1
data/lib/mutineer/config.rb
CHANGED
|
@@ -24,12 +24,14 @@ module Mutineer
|
|
|
24
24
|
:sources, :tests, :operators, :threshold, :only, :dry_run,
|
|
25
25
|
:cache_dir, :project_root, :load_paths,
|
|
26
26
|
:jobs, :format, :output, :strategy, :require_paths,
|
|
27
|
-
:boot, :rails, :since, :framework,
|
|
27
|
+
:boot, :rails, :since, :framework, :verbose, :ignore,
|
|
28
|
+
:baseline, :baseline_epsilon,
|
|
28
29
|
keyword_init: true
|
|
29
30
|
) do
|
|
31
|
+
# Config file name.
|
|
30
32
|
CONFIG_FILE = ".mutineer.yml"
|
|
31
33
|
# Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
|
|
32
|
-
KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework].freeze
|
|
34
|
+
KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline].freeze
|
|
33
35
|
|
|
34
36
|
def initialize(**kwargs)
|
|
35
37
|
super
|
|
@@ -45,6 +47,9 @@ module Mutineer
|
|
|
45
47
|
self.strategy ||= "reload"
|
|
46
48
|
self.require_paths ||= []
|
|
47
49
|
self.rails = false if rails.nil?
|
|
50
|
+
self.verbose = false if verbose.nil?
|
|
51
|
+
self.ignore ||= []
|
|
52
|
+
self.baseline_epsilon ||= 0.0
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
# Walk from `start` toward `home`, returning the first .mutineer.yml path found
|
|
@@ -124,16 +129,30 @@ module Mutineer
|
|
|
124
129
|
|
|
125
130
|
# Pick rspec when a MAJORITY of the given test files end with _spec.rb;
|
|
126
131
|
# otherwise minitest. Empty/ambiguous -> minitest (the safe default).
|
|
132
|
+
# Detects the test framework from the file list.
|
|
133
|
+
#
|
|
134
|
+
# @param tests [Array<String>] test file paths.
|
|
135
|
+
# @return [String] `"rspec"` or `"minitest"`.
|
|
127
136
|
def self.detect_framework(tests)
|
|
128
137
|
tests = Array(tests)
|
|
129
138
|
specs = tests.count { |t| t.to_s.end_with?("_spec.rb") }
|
|
130
139
|
specs > tests.length / 2.0 ? "rspec" : "minitest"
|
|
131
140
|
end
|
|
132
141
|
|
|
142
|
+
# Maps a config key to its Struct field.
|
|
143
|
+
#
|
|
144
|
+
# @param known_key [String] config key.
|
|
145
|
+
# @return [Symbol] struct field name.
|
|
133
146
|
def self.field_for(known_key)
|
|
134
147
|
known_key == "require" ? :require_paths : known_key.to_sym
|
|
135
148
|
end
|
|
136
149
|
|
|
150
|
+
# Coerces a config value to its target type.
|
|
151
|
+
#
|
|
152
|
+
# @param known_key [String] config key.
|
|
153
|
+
# @param value [Object] raw YAML value.
|
|
154
|
+
# @param file_name [String] config file name for warnings.
|
|
155
|
+
# @return [Object] coerced value.
|
|
137
156
|
def self.coerce(known_key, value, file_name)
|
|
138
157
|
case known_key
|
|
139
158
|
when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
|
|
@@ -143,6 +162,9 @@ module Mutineer
|
|
|
143
162
|
when "boot" then value.to_s
|
|
144
163
|
when "framework" then value.to_s
|
|
145
164
|
when "rails" then value == true || value.to_s == "true"
|
|
165
|
+
when "verbose" then value == true || value.to_s == "true"
|
|
166
|
+
when "ignore" then Array(value).map(&:to_s)
|
|
167
|
+
when "baseline" then value.to_s
|
|
146
168
|
else value
|
|
147
169
|
end
|
|
148
170
|
end
|
|
@@ -150,6 +172,12 @@ module Mutineer
|
|
|
150
172
|
# Drop (with a warning) operator names the registry doesn't know (R7).
|
|
151
173
|
# Referenced lazily so config.rb carries no load-order dependency on the
|
|
152
174
|
# registry; by the time a config is parsed at runtime, it is loaded.
|
|
175
|
+
# Filters out unknown operator names.
|
|
176
|
+
#
|
|
177
|
+
# @api private
|
|
178
|
+
# @param names [Array<String>] operator names.
|
|
179
|
+
# @param file_name [String] config file name for warnings.
|
|
180
|
+
# @return [Array<String>] known operator names.
|
|
153
181
|
def self.filter_operators(names, file_name)
|
|
154
182
|
known = MutatorRegistry::ALL.keys
|
|
155
183
|
names.select do |n|
|
|
@@ -6,6 +6,7 @@ require "digest"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "rbconfig"
|
|
8
8
|
require "coverage"
|
|
9
|
+
require "set"
|
|
9
10
|
require_relative "minitest_integration"
|
|
10
11
|
require_relative "test_runners"
|
|
11
12
|
|
|
@@ -25,7 +26,7 @@ module Mutineer
|
|
|
25
26
|
def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
|
|
26
27
|
load_paths: ["lib"], project_root: Dir.pwd,
|
|
27
28
|
capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
|
|
28
|
-
framework: "minitest")
|
|
29
|
+
framework: "minitest", verbose: false)
|
|
29
30
|
@source_paths = Array(source_paths)
|
|
30
31
|
@test_paths = Array(test_paths)
|
|
31
32
|
@cache_dir = cache_dir
|
|
@@ -34,6 +35,7 @@ module Mutineer
|
|
|
34
35
|
@capture_timeout = capture_timeout
|
|
35
36
|
@boot_path = boot_path
|
|
36
37
|
@framework = framework || "minitest"
|
|
38
|
+
@verbose = verbose
|
|
37
39
|
@map = {}
|
|
38
40
|
@failed_test_files = []
|
|
39
41
|
@phase_a_ran = false
|
|
@@ -64,8 +66,40 @@ module Mutineer
|
|
|
64
66
|
@map["#{relativize(file)}:#{line}"] || []
|
|
65
67
|
end
|
|
66
68
|
|
|
69
|
+
# #9: is this source file's empty coverage the result of an *errored* capture
|
|
70
|
+
# rather than a genuine coverage gap? True iff (KTD-2) some capture failed this
|
|
71
|
+
# run AND this file got zero coverage from any successful capture AND a failed
|
|
72
|
+
# test file maps to it by the standard _test/_spec naming convention. Derived
|
|
73
|
+
# purely from already-persisted state (@map keys + @failed_test_files); no rerun,
|
|
74
|
+
# no new cached field, no digest change.
|
|
75
|
+
#
|
|
76
|
+
# ponytail: file-level, convention-based attribution. A line covered only by a
|
|
77
|
+
# failed test in an otherwise-covered file stays no_coverage (condition 2), and
|
|
78
|
+
# a source with no naming-convention test match is never tainted. Upgrade path:
|
|
79
|
+
# persist per-file coverage per successful run and diff against the failed set,
|
|
80
|
+
# or record test->source targets explicitly. Not needed for the #8/#9 cases.
|
|
81
|
+
def uncapturable_source?(file)
|
|
82
|
+
return false if @failed_test_files.empty?
|
|
83
|
+
|
|
84
|
+
rel = relativize(absolute(file))
|
|
85
|
+
return false if covered_source_files.include?(rel)
|
|
86
|
+
|
|
87
|
+
failed_test_targets.include?(File.basename(rel, ".rb"))
|
|
88
|
+
end
|
|
89
|
+
|
|
67
90
|
private
|
|
68
91
|
|
|
92
|
+
# Source rel-paths that received coverage from any successful capture.
|
|
93
|
+
def covered_source_files
|
|
94
|
+
@map.keys.map { |k| k.rpartition(":").first }.to_set
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Basenames of failed test files with a trailing _test/_spec (and .rb) stripped,
|
|
98
|
+
# i.e. the source basenames they would have covered by convention.
|
|
99
|
+
def failed_test_targets
|
|
100
|
+
@failed_test_files.map { |t| File.basename(t, ".rb").sub(/_(test|spec)\z/, "") }.to_set
|
|
101
|
+
end
|
|
102
|
+
|
|
69
103
|
# Shared cache dance for both build paths: hit the digest-keyed cache, else
|
|
70
104
|
# yield to populate @map and persist it.
|
|
71
105
|
def cached_or
|
|
@@ -83,6 +117,9 @@ module Mutineer
|
|
|
83
117
|
self
|
|
84
118
|
end
|
|
85
119
|
|
|
120
|
+
# Runs standalone Phase A coverage capture.
|
|
121
|
+
#
|
|
122
|
+
# @api private
|
|
86
123
|
def run_phase_a
|
|
87
124
|
@phase_a_ran = true
|
|
88
125
|
@map = {}
|
|
@@ -108,10 +145,16 @@ module Mutineer
|
|
|
108
145
|
abs_sources = abs_source_paths
|
|
109
146
|
|
|
110
147
|
@test_paths.each do |test_path|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
148
|
+
# Tri-state payload (KTD-1): Hash = coverage, String = error diagnostic
|
|
149
|
+
# from the child, nil = pipe gone / empty.
|
|
150
|
+
# ponytail/#9: this String diagnostic is what #9 turns into an :uncapturable status.
|
|
151
|
+
case (coverage = fork_capture(absolute(test_path), abs_sources, rails))
|
|
152
|
+
when Hash then record(coverage, test_path)
|
|
153
|
+
when String
|
|
154
|
+
fail_test(test_path, @verbose ? "fork capture failed: #{coverage}" :
|
|
155
|
+
"fork capture produced no result (re-run with --verbose for the error)")
|
|
156
|
+
else fail_test(test_path, "fork capture produced no result")
|
|
157
|
+
end
|
|
115
158
|
end
|
|
116
159
|
end
|
|
117
160
|
|
|
@@ -120,6 +163,11 @@ module Mutineer
|
|
|
120
163
|
# fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
|
|
121
164
|
def fork_capture(abs_test, abs_sources, rails)
|
|
122
165
|
rd, wr = IO.pipe
|
|
166
|
+
# #19: Marshal output is binary — an un-binmoded pipe can raise
|
|
167
|
+
# Encoding::UndefinedConversionError on write, which the child's rescue then
|
|
168
|
+
# swallows, losing the real error and yielding a bare "no result".
|
|
169
|
+
rd.binmode
|
|
170
|
+
wr.binmode
|
|
123
171
|
pid = fork do
|
|
124
172
|
rd.close
|
|
125
173
|
payload =
|
|
@@ -132,8 +180,10 @@ module Mutineer
|
|
|
132
180
|
Coverage.result(stop: false)
|
|
133
181
|
.select { |f, _| abs_sources.include?(f) }
|
|
134
182
|
.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
135
|
-
rescue Exception # rubocop:disable Lint/RescueException
|
|
136
|
-
|
|
183
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
184
|
+
# KTD-1: stringify (an arbitrary Exception may not marshal); the parent
|
|
185
|
+
# surfaces this under --verbose. A String marshals safely over the pipe.
|
|
186
|
+
"#{e.class}: #{e.message}#{e.backtrace&.first ? " @ #{e.backtrace.first}" : ''}"
|
|
137
187
|
end
|
|
138
188
|
begin
|
|
139
189
|
wr.write(Marshal.dump(payload))
|
|
@@ -147,12 +197,30 @@ module Mutineer
|
|
|
147
197
|
wr.close
|
|
148
198
|
data = rd.read
|
|
149
199
|
rd.close
|
|
150
|
-
Process.
|
|
151
|
-
|
|
200
|
+
_, status = Process.waitpid2(pid)
|
|
201
|
+
# #19: an empty pipe means the child died before writing (e.g. a hard crash,
|
|
202
|
+
# OOM, or a signal from the test's own subprocess handling). Report HOW it
|
|
203
|
+
# died (exit status / signal) as a diagnostic string so --verbose has
|
|
204
|
+
# something actionable instead of a silent "no result".
|
|
205
|
+
return "child wrote no result (#{describe_status(status)})" if data.empty?
|
|
152
206
|
|
|
153
207
|
Marshal.load(data)
|
|
154
|
-
rescue StandardError
|
|
155
|
-
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
"parent could not read capture result: #{e.class}: #{e.message}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Human description of a child Process::Status for capture diagnostics.
|
|
213
|
+
#
|
|
214
|
+
# @api private
|
|
215
|
+
# @param status [Process::Status] the reaped child status.
|
|
216
|
+
# @return [String] e.g. "killed by signal 9 (SIGKILL)" or "exit status 1".
|
|
217
|
+
def describe_status(status)
|
|
218
|
+
if status.signaled?
|
|
219
|
+
sig = status.termsig
|
|
220
|
+
"killed by signal #{sig}#{Signal.signame(sig) ? " (SIG#{Signal.signame(sig)})" : ''}"
|
|
221
|
+
else
|
|
222
|
+
"exit status #{status.exitstatus.inspect}"
|
|
223
|
+
end
|
|
156
224
|
end
|
|
157
225
|
|
|
158
226
|
# Spawns a fresh `ruby` reading an inline script from stdin. A fork would
|
|
@@ -183,6 +251,12 @@ module Mutineer
|
|
|
183
251
|
fail_test(test_path, "invalid coverage output: #{e.message}")
|
|
184
252
|
end
|
|
185
253
|
|
|
254
|
+
# Records a failed coverage capture.
|
|
255
|
+
#
|
|
256
|
+
# @api private
|
|
257
|
+
# @param test_path [String] test file path.
|
|
258
|
+
# @param reason [String] failure reason.
|
|
259
|
+
# @return [void]
|
|
186
260
|
def fail_test(test_path, reason)
|
|
187
261
|
rel = relativize(test_path)
|
|
188
262
|
@failed_test_files << rel
|
|
@@ -190,10 +264,20 @@ module Mutineer
|
|
|
190
264
|
nil
|
|
191
265
|
end
|
|
192
266
|
|
|
267
|
+
# Builds the framework-specific subprocess script.
|
|
268
|
+
#
|
|
269
|
+
# @api private
|
|
270
|
+
# @param test_path [String] test file path.
|
|
271
|
+
# @return [String] Ruby script text.
|
|
193
272
|
def subprocess_script(test_path)
|
|
194
273
|
@framework == "rspec" ? rspec_subprocess_script(test_path) : minitest_subprocess_script(test_path)
|
|
195
274
|
end
|
|
196
275
|
|
|
276
|
+
# Builds the minitest subprocess script.
|
|
277
|
+
#
|
|
278
|
+
# @api private
|
|
279
|
+
# @param test_path [String] test file path.
|
|
280
|
+
# @return [String] Ruby script text.
|
|
197
281
|
def minitest_subprocess_script(test_path)
|
|
198
282
|
<<~RUBY
|
|
199
283
|
require "coverage"
|
|
@@ -280,6 +364,13 @@ module Mutineer
|
|
|
280
364
|
File.exist?(absolute(@boot_path)) ? @boot_path : "#{@boot_path}.rb"
|
|
281
365
|
end
|
|
282
366
|
|
|
367
|
+
# Groups a digest with its role and paths.
|
|
368
|
+
#
|
|
369
|
+
# @api private
|
|
370
|
+
# @param digest [String] digest string.
|
|
371
|
+
# @param role [String] digest role.
|
|
372
|
+
# @param paths [Array<String>] paths in the digest group.
|
|
373
|
+
# @return [Array(String, String, Array<String>)] grouped digest data.
|
|
283
374
|
def digest_group(digest, role, paths)
|
|
284
375
|
paths.sort.each do |p|
|
|
285
376
|
content = File.read(absolute(p))
|
|
@@ -305,8 +396,16 @@ module Mutineer
|
|
|
305
396
|
end
|
|
306
397
|
end
|
|
307
398
|
|
|
399
|
+
# Returns the cache path.
|
|
400
|
+
#
|
|
401
|
+
# @api private
|
|
402
|
+
# @return [String] cache file path.
|
|
308
403
|
def cache_path = File.join(@cache_dir, "coverage.json")
|
|
309
404
|
|
|
405
|
+
# Reads the coverage cache.
|
|
406
|
+
#
|
|
407
|
+
# @api private
|
|
408
|
+
# @return [Hash, nil] cached payload.
|
|
310
409
|
def read_cache
|
|
311
410
|
return nil unless File.exist?(cache_path)
|
|
312
411
|
|
|
@@ -315,6 +414,10 @@ module Mutineer
|
|
|
315
414
|
nil # corrupt cache — rebuild from scratch
|
|
316
415
|
end
|
|
317
416
|
|
|
417
|
+
# Saves the coverage cache.
|
|
418
|
+
#
|
|
419
|
+
# @api private
|
|
420
|
+
# @return [void]
|
|
318
421
|
def save
|
|
319
422
|
FileUtils.mkdir_p(@cache_dir)
|
|
320
423
|
data = { "digest" => @digest, "failed_test_files" => @failed_test_files, "map" => @map }
|
|
@@ -323,20 +426,41 @@ module Mutineer
|
|
|
323
426
|
File.rename(tmp, cache_path) # atomic swap
|
|
324
427
|
end
|
|
325
428
|
|
|
429
|
+
# Warns when coverage capture was incomplete.
|
|
430
|
+
#
|
|
431
|
+
# @api private
|
|
432
|
+
# @return [void]
|
|
326
433
|
def warn_incomplete
|
|
327
434
|
warn "[mutineer] cached coverage map may be incomplete; these test files " \
|
|
328
435
|
"failed to contribute: #{@failed_test_files.join(', ')}"
|
|
329
436
|
end
|
|
330
437
|
|
|
438
|
+
# Returns absolute source paths.
|
|
439
|
+
#
|
|
440
|
+
# @return [Array<String>] absolute source paths.
|
|
331
441
|
def abs_source_paths = @source_paths.map { |p| absolute(p) }
|
|
442
|
+
|
|
443
|
+
# Returns absolute load paths.
|
|
444
|
+
#
|
|
445
|
+
# @return [Array<String>] absolute load paths.
|
|
332
446
|
def abs_load_paths = @load_paths.map { |p| absolute(p) }
|
|
333
447
|
|
|
448
|
+
# Relativizes a path against the project root.
|
|
449
|
+
#
|
|
450
|
+
# @api private
|
|
451
|
+
# @param path [String] path to relativize.
|
|
452
|
+
# @return [String] relative path.
|
|
334
453
|
def relativize(path)
|
|
335
454
|
return path unless path.start_with?("/")
|
|
336
455
|
|
|
337
456
|
path.delete_prefix("#{@project_root}/")
|
|
338
457
|
end
|
|
339
458
|
|
|
459
|
+
# Expands a path relative to the project root.
|
|
460
|
+
#
|
|
461
|
+
# @api private
|
|
462
|
+
# @param path [String] path to expand.
|
|
463
|
+
# @return [String] absolute path.
|
|
340
464
|
def absolute(path)
|
|
341
465
|
File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
|
|
342
466
|
end
|
data/lib/mutineer/isolation.rb
CHANGED
|
@@ -10,20 +10,25 @@ module Mutineer
|
|
|
10
10
|
# exit status into a Result.
|
|
11
11
|
#
|
|
12
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
|
|
14
|
-
# by the parent's monitor flag, not by status.signaled? (which is
|
|
15
|
-
# signal death, e.g. SIGSEGV — it cannot tell our SIGKILL apart
|
|
13
|
+
# child's status): 0 => survived, 1 => killed, 2 => error. Timeout is
|
|
14
|
+
# detected by the parent's monitor flag, not by status.signaled? (which is
|
|
15
|
+
# true for ANY signal death, e.g. SIGSEGV — it cannot tell our SIGKILL apart
|
|
16
|
+
# from the OS's).
|
|
16
17
|
#
|
|
17
|
-
# mutineer: the reload strategy this enables (whole-file `load`) re-executes
|
|
18
|
-
# entire file — any top-level code runs again. Acceptable for POROs;
|
|
19
|
-
# if users hit issues with initializers/callbacks. Alternative: the
|
|
20
|
-
# strategy (surgical single-method redefinition).
|
|
18
|
+
# mutineer: the reload strategy this enables (whole-file `load`) re-executes
|
|
19
|
+
# the entire file — any top-level code runs again. Acceptable for POROs;
|
|
20
|
+
# document if users hit issues with initializers/callbacks. Alternative: the
|
|
21
|
+
# redefine strategy (surgical single-method redefinition).
|
|
21
22
|
class Isolation
|
|
22
23
|
DEFAULT_TIMEOUT = 10 # seconds
|
|
23
24
|
|
|
24
25
|
# Runs the block in a forked child. The block's return value (an Integer
|
|
25
26
|
# exit code) or any explicit `exit` is honoured; an unhandled exception
|
|
26
27
|
# becomes exit 2 with the cause written to STDERR.
|
|
28
|
+
#
|
|
29
|
+
# @param timeout [Integer] timeout in seconds.
|
|
30
|
+
# @yieldreturn [Integer] child exit status.
|
|
31
|
+
# @return [Mutineer::Result] result from the child process.
|
|
27
32
|
def self.run(timeout: DEFAULT_TIMEOUT)
|
|
28
33
|
pid = fork do
|
|
29
34
|
code = 0
|
|
@@ -43,11 +48,12 @@ module Mutineer
|
|
|
43
48
|
exit!(code)
|
|
44
49
|
end
|
|
45
50
|
|
|
46
|
-
# Single-threaded deadline poll (R2): we are the ONLY caller of waitpid
|
|
47
|
-
# this pid, so we never reap-then-kill. We SIGKILL only after WNOHANG
|
|
48
|
-
# the child is still alive past the deadline — so the kill can
|
|
49
|
-
# reaped/recycled pid. Timeout is a parent-side fact
|
|
50
|
-
# status.signaled? (which is true for ANY signal
|
|
51
|
+
# Single-threaded deadline poll (R2): we are the ONLY caller of waitpid
|
|
52
|
+
# on this pid, so we never reap-then-kill. We SIGKILL only after WNOHANG
|
|
53
|
+
# shows the child is still alive past the deadline — so the kill can
|
|
54
|
+
# never hit a reaped/recycled pid. Timeout is a parent-side fact
|
|
55
|
+
# (deadline reached), not status.signaled? (which is true for ANY signal
|
|
56
|
+
# death, e.g. SIGSEGV).
|
|
51
57
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
52
58
|
loop do
|
|
53
59
|
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
@@ -63,13 +69,19 @@ module Mutineer
|
|
|
63
69
|
end
|
|
64
70
|
|
|
65
71
|
# 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-
|
|
67
|
-
# side effects. Child-only — mutates the loaded program.
|
|
72
|
+
# reopens its classes and redefines every method in place. Re-runs file-
|
|
73
|
+
# level side effects. Child-only — mutates the loaded program.
|
|
68
74
|
#
|
|
69
75
|
# 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
|
|
71
|
-
# its real neighbours (e.g. a mutator's `require_relative
|
|
72
|
-
# elsewhere makes those requires resolve to the temp
|
|
76
|
+
# temp dir, so any `require_relative` in the mutated source resolves
|
|
77
|
+
# against its real neighbours (e.g. a mutator's `require_relative
|
|
78
|
+
# "base"`). Writing it elsewhere makes those requires resolve to the temp
|
|
79
|
+
# dir and raise LoadError.
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
# @param mutated [String] mutated source text.
|
|
83
|
+
# @param source_file [String] original source file path.
|
|
84
|
+
# @return [Object] whatever `load` returns.
|
|
73
85
|
def self.apply_whole_file(mutated, source_file)
|
|
74
86
|
Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(source_file)) do |f|
|
|
75
87
|
f.write(mutated)
|
|
@@ -78,47 +90,69 @@ module Mutineer
|
|
|
78
90
|
end
|
|
79
91
|
end
|
|
80
92
|
|
|
81
|
-
# Redefine strategy: extract just the enclosing DefNode, apply the mutation
|
|
82
|
-
# that snippet, wrap it in its real namespace, and `load` only that one
|
|
83
|
-
# back into the running process. No file-level side effects re-run.
|
|
93
|
+
# Redefine strategy: extract just the enclosing DefNode, apply the mutation
|
|
94
|
+
# to that snippet, wrap it in its real namespace, and `load` only that one
|
|
95
|
+
# method back into the running process. No file-level side effects re-run.
|
|
96
|
+
# Child-only.
|
|
84
97
|
#
|
|
85
98
|
# The snippet keeps its own `def self.x` for singletons, so the namespace
|
|
86
99
|
# wrapper redefines instance and singleton methods correctly without any
|
|
87
100
|
# special-casing.
|
|
101
|
+
#
|
|
102
|
+
# @api private
|
|
103
|
+
# @param mutation [Mutineer::Mutation] mutation to apply.
|
|
104
|
+
# @param subject [Mutineer::Subject] subject being mutated.
|
|
105
|
+
# @param source [String] full source text.
|
|
106
|
+
# @return [Object] whatever `load` returns.
|
|
88
107
|
def self.apply_surgical(mutation, subject, source)
|
|
89
108
|
loc = subject.def_node.location
|
|
90
109
|
def_start = loc.start_offset
|
|
91
|
-
# Byte slicing (C1): Prism offsets are byte offsets.
|
|
92
110
|
snippet = source.byteslice(def_start...loc.end_offset)
|
|
93
111
|
rel_s = mutation.start_offset - def_start
|
|
94
112
|
rel_e = mutation.end_offset - def_start
|
|
95
113
|
mutated_def = snippet.byteslice(0...rel_s) + mutation.replacement + snippet.byteslice(rel_e..)
|
|
96
114
|
|
|
97
115
|
# Rebuild the FULL namespace nesting textually so unqualified enclosing-
|
|
98
|
-
# namespace constants resolve exactly as the reload strategy would. A
|
|
99
|
-
# redefinition on the owner would collapse Module.nesting to [owner]
|
|
100
|
-
# raise NameError on such constants (C2 scope-collapse).
|
|
116
|
+
# namespace constants resolve exactly as the reload strategy would. A
|
|
117
|
+
# bare redefinition on the owner would collapse Module.nesting to [owner]
|
|
118
|
+
# and raise NameError on such constants (C2 scope-collapse).
|
|
101
119
|
keywords = nesting_keywords(subject.namespace)
|
|
102
120
|
prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n")
|
|
103
121
|
prefix += "\n" unless prefix.empty?
|
|
104
|
-
wrapped = "#{prefix}#{mutated_def}#{"\nend" * keywords.size}"
|
|
105
122
|
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
123
|
+
# #20: a singleton method whose def has NO `self.` receiver (the
|
|
124
|
+
# `class << self` and `module_function` forms) would, as a bare `def foo`
|
|
125
|
+
# inside `module Owner`, redefine the INSTANCE method — but the call
|
|
126
|
+
# (`Owner.foo`) dispatches to the singleton, so the mutant never runs and
|
|
127
|
+
# falsely survives. Re-open the singleton class so the redefinition lands on
|
|
128
|
+
# the same method the test calls. `def self.foo` already carries its
|
|
129
|
+
# receiver, so it is left as-is (wrapping it would mis-target).
|
|
130
|
+
inner =
|
|
131
|
+
if subject.singleton && subject.def_node.receiver.nil?
|
|
132
|
+
"class << self\n#{mutated_def}\nend"
|
|
133
|
+
else
|
|
134
|
+
mutated_def
|
|
135
|
+
end
|
|
136
|
+
wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
|
|
137
|
+
|
|
138
|
+
# A snippet that fails to reparse must NOT silently fall through to
|
|
139
|
+
# running the ORIGINAL method (C2 false-survived). Raise -> the fork
|
|
140
|
+
# block aborts before any test runs -> Result.error, never a bogus
|
|
141
|
+
# `survived`.
|
|
109
142
|
raise "surgical snippet failed to reparse" if Parser.parse_string(wrapped).errors.any?
|
|
110
143
|
|
|
111
|
-
# Preserve original visibility — class/module bodies define methods
|
|
112
|
-
# but 7a's `load` would re-apply the file's private/protected
|
|
144
|
+
# Preserve original visibility — class/module bodies define methods
|
|
145
|
+
# public, but 7a's `load` would re-apply the file's private/protected
|
|
146
|
+
# (C2).
|
|
113
147
|
owner = subject.namespace.empty? ? Object : Object.const_get(subject.namespace.join("::"))
|
|
114
148
|
target = subject.singleton ? owner.singleton_class : owner
|
|
115
149
|
vis = method_visibility(target, subject.name)
|
|
116
150
|
|
|
117
|
-
# Write the wrapped snippet to a tempfile and `load` it: `load` runs it
|
|
118
|
-
# top level, so the textual class/module wrappers rebuild
|
|
119
|
-
# identically, with no dynamic string execution for
|
|
120
|
-
# input is the project's OWN source (the enclosing
|
|
121
|
-
# mutated), loaded only in this forked child.
|
|
151
|
+
# Write the wrapped snippet to a tempfile and `load` it: `load` runs it
|
|
152
|
+
# at top level, so the textual class/module wrappers rebuild
|
|
153
|
+
# Module.nesting identically, with no dynamic string execution for
|
|
154
|
+
# scanners to flag. The input is the project's OWN source (the enclosing
|
|
155
|
+
# method, textually mutated), loaded only in this forked child.
|
|
122
156
|
Tempfile.create(["mutineer_surgical", ".rb"]) do |f|
|
|
123
157
|
f.write(wrapped)
|
|
124
158
|
f.flush
|
|
@@ -132,11 +166,15 @@ module Mutineer
|
|
|
132
166
|
# keyword (reopening a class with `module` — or vice versa — raises
|
|
133
167
|
# TypeError), so the textual wrapper matches the real definitions.
|
|
134
168
|
#
|
|
135
|
-
# #5: a compact element like "Foo::Bar" stays a SINGLE wrapper `class Foo::
|
|
136
|
-
# (nesting [Foo::Bar]), matching how a whole-file load (reload) sees
|
|
137
|
-
# Splitting it into `module Foo; class Bar` gave nesting [Foo::Bar,
|
|
138
|
-
# unqualified constant defined only in Foo would resolve under
|
|
139
|
-
# reload — a strategy disagreement.
|
|
169
|
+
# #5: a compact element like "Foo::Bar" stays a SINGLE wrapper `class Foo::
|
|
170
|
+
# Bar` (nesting [Foo::Bar]), matching how a whole-file load (reload) sees
|
|
171
|
+
# it. Splitting it into `module Foo; class Bar` gave nesting [Foo::Bar,
|
|
172
|
+
# Foo], so an unqualified constant defined only in Foo would resolve under
|
|
173
|
+
# redefine but not reload — a strategy disagreement.
|
|
174
|
+
#
|
|
175
|
+
# @api private
|
|
176
|
+
# @param namespace [Array<String>] namespace components.
|
|
177
|
+
# @return [Array<[String, String]>] wrapper keywords and names.
|
|
140
178
|
def self.nesting_keywords(namespace)
|
|
141
179
|
mod = Object
|
|
142
180
|
namespace.map do |name|
|
|
@@ -145,6 +183,12 @@ module Mutineer
|
|
|
145
183
|
end
|
|
146
184
|
end
|
|
147
185
|
|
|
186
|
+
# Returns the visibility for a method name.
|
|
187
|
+
#
|
|
188
|
+
# @api private
|
|
189
|
+
# @param mod [Module] module or class being inspected.
|
|
190
|
+
# @param name [Symbol] method name.
|
|
191
|
+
# @return [Symbol, nil] `:public`, `:protected`, `:private`, or nil.
|
|
148
192
|
def self.method_visibility(mod, name)
|
|
149
193
|
return :private if mod.private_method_defined?(name)
|
|
150
194
|
return :protected if mod.protected_method_defined?(name)
|
|
@@ -153,6 +197,11 @@ module Mutineer
|
|
|
153
197
|
nil
|
|
154
198
|
end
|
|
155
199
|
|
|
200
|
+
# Decodes a child status into a Result.
|
|
201
|
+
#
|
|
202
|
+
# @api private
|
|
203
|
+
# @param status [Process::Status] child exit status.
|
|
204
|
+
# @return [Mutineer::Result] decoded result.
|
|
156
205
|
def self.decode(status)
|
|
157
206
|
case status.exitstatus
|
|
158
207
|
when 0 then Result.survived
|
|
@@ -11,18 +11,19 @@ module Mutineer
|
|
|
11
11
|
# (autorun, runnables) that only makes sense in a throwaway forked child.
|
|
12
12
|
#
|
|
13
13
|
# No `rescue` here: Isolation.run's fork block is the single exception
|
|
14
|
-
# boundary (any exception there becomes exit 2). Adding a rescue would
|
|
15
|
-
# a second exit-2 path and break this method's 0/1 return contract.
|
|
14
|
+
# boundary (any exception there becomes exit 2). Adding a rescue would
|
|
15
|
+
# create a second exit-2 path and break this method's 0/1 return contract.
|
|
16
16
|
class MinitestIntegration
|
|
17
17
|
# ponytail: tested via runner_test.rb (U6), not in isolation — a direct
|
|
18
18
|
# unit test would require forking and duplicate isolation_test's coverage.
|
|
19
19
|
#
|
|
20
|
-
# `test_files` is one path or an Array of paths (M3 coverage selection
|
|
21
|
-
# the covering subset); each is loaded before the single
|
|
20
|
+
# `test_files` is one path or an Array of paths (M3 coverage selection
|
|
21
|
+
# passes the covering subset); each is loaded before the single
|
|
22
|
+
# Minitest.run.
|
|
23
|
+
#
|
|
24
|
+
# @param test_files [String, Array<String>] one file or many files.
|
|
25
|
+
# @return [Integer] 0 on success, 1 on failure.
|
|
22
26
|
def self.run(test_files)
|
|
23
|
-
# minitest is required LAZILY (never at load time) so Mutineer keeps zero
|
|
24
|
-
# runtime gem deps and loads fine in an rspec-only project; a missing
|
|
25
|
-
# minitest raises a clear Mutineer error rather than a LoadError backtrace.
|
|
26
27
|
begin
|
|
27
28
|
require "minitest"
|
|
28
29
|
rescue LoadError
|
|
@@ -31,6 +32,10 @@ module Mutineer
|
|
|
31
32
|
"or use --framework rspec"
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
# minitest is required LAZILY (never at load time) so Mutineer keeps
|
|
36
|
+
# zero runtime gem deps and loads fine in an rspec-only project; a missing
|
|
37
|
+
# minitest raises a clear Mutineer error rather than a LoadError backtrace.
|
|
38
|
+
|
|
34
39
|
# Neutralise autorun so a test file's `require "minitest/autorun"`
|
|
35
40
|
# registers no at_exit hook.
|
|
36
41
|
def Minitest.autorun; end # rubocop:disable Lint/NestedMethodDefinition
|
|
@@ -38,11 +43,10 @@ module Mutineer
|
|
|
38
43
|
# Drop runnables inherited from the parent suite (this is the child's
|
|
39
44
|
# private copy — the parent is unaffected) so only the target test runs.
|
|
40
45
|
Minitest::Runnable.reset
|
|
41
|
-
|
|
42
46
|
Array(test_files).each { |f| load f }
|
|
43
47
|
|
|
44
|
-
# Silence the child's test output; the parent only cares about pass/fail.
|
|
45
48
|
orig = $stdout
|
|
49
|
+
# Silence the child's test output; the parent only cares about pass/fail.
|
|
46
50
|
$stdout = StringIO.new
|
|
47
51
|
passed = Minitest.run([])
|
|
48
52
|
$stdout = orig
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
# Content-based stable id for a mutant — NOT byte offsets. Pure function, reused
|
|
7
|
+
# by the Runner (matching the ignore list), the Reporter (emitting a copy-
|
|
8
|
+
# pasteable id per survivor), and #13 baseline gating (diffing id-sets run to
|
|
9
|
+
# run). `digest` is stdlib, so zero new deps.
|
|
10
|
+
#
|
|
11
|
+
# Offset-free by design: keyed on the subject's qualified_name (a method, not a
|
|
12
|
+
# byte position) + operator + the normalized mutated token + an occurrence
|
|
13
|
+
# ordinal among same-(operator, token) twins WITHIN the subject. So it survives
|
|
14
|
+
# any edit outside the subject method — where raw start/end offsets shift on
|
|
15
|
+
# every edit earlier in the file and would silently stop matching.
|
|
16
|
+
module MutantId
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Computes the stable id for a single mutant.
|
|
20
|
+
#
|
|
21
|
+
# NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
|
|
22
|
+
# with the separator; SHA256[0,12] gives a fixed-length, copy-pasteable key.
|
|
23
|
+
#
|
|
24
|
+
# @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
|
|
25
|
+
# its `qualified_name` anchors the id to a method rather than a byte position.
|
|
26
|
+
# @param mutation [Mutineer::Mutation] the atomic edit whose operator is hashed.
|
|
27
|
+
# @param source [String] the full, unmutated source the mutation indexes into.
|
|
28
|
+
# @param occurrence [Integer] 0-based ordinal among twins sharing the same
|
|
29
|
+
# (operator, token) within the subject, disambiguating otherwise-identical mutants.
|
|
30
|
+
# @return [String] a 12-character hex id, stable across edits outside the subject.
|
|
31
|
+
def for(subject, mutation, source, occurrence = 0)
|
|
32
|
+
Digest::SHA256.hexdigest(
|
|
33
|
+
[subject.qualified_name, mutation.operator,
|
|
34
|
+
normalized_token(mutation, source), occurrence].join("\x00")
|
|
35
|
+
)[0, 12]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Computes ids for a subject's full mutation list, in input order, assigning
|
|
39
|
+
# each its 0-based occurrence among twins sharing the same (operator, token).
|
|
40
|
+
# This is what disambiguates `a + b + c`'s two `+` mutants without an offset.
|
|
41
|
+
#
|
|
42
|
+
# @param subject [Mutineer::Subject] the subject the mutations belong to.
|
|
43
|
+
# @param source [String] the full, unmutated source for token normalization.
|
|
44
|
+
# @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
|
|
45
|
+
# @return [Array<String>] one 12-character id per mutation, positionally aligned.
|
|
46
|
+
def for_subject(subject, source, mutations)
|
|
47
|
+
seen = Hash.new(0)
|
|
48
|
+
mutations.map do |m|
|
|
49
|
+
key = [m.operator, normalized_token(m, source)]
|
|
50
|
+
occ = seen[key]
|
|
51
|
+
seen[key] += 1
|
|
52
|
+
self.for(subject, m, source, occ)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Extracts the exact code being mutated, whitespace-collapsed — the same
|
|
57
|
+
# normalization the Reporter's `diff_for` uses for its token label.
|
|
58
|
+
#
|
|
59
|
+
# @param mutation [Mutineer::Mutation] supplies the byte range to slice.
|
|
60
|
+
# @param source [String] the source to byteslice (byte offsets, never char).
|
|
61
|
+
# @return [String] the mutated token with runs of whitespace collapsed to one space.
|
|
62
|
+
def normalized_token(mutation, source)
|
|
63
|
+
source.byteslice(mutation.start_offset...mutation.end_offset).gsub(/\s+/, " ").strip
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|