mutineer 1.0.0 → 1.0.1
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 +27 -0
- data/lib/mutineer/cli.rb +5 -0
- data/lib/mutineer/coverage_map.rb +404 -30
- data/lib/mutineer/daemon_backend.rb +11 -0
- data/lib/mutineer/daemon_client.rb +7 -4
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/file_swap.rb +136 -40
- data/lib/mutineer/runner.rb +55 -33
- data/lib/mutineer/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a96e7bad1e1cf8d106957e770418e6a1854c677943114b9bc45421c4e06da7f2
|
|
4
|
+
data.tar.gz: c404d87071f3d270566e368ac70153f1e87c940eb170b220b972fb1b28b29889
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15417ab5322c43c537953eaa438c18a9a89794f199a9c4666f031daf60f55f6c3ffabcb2e69e15b7ffd030e48b6f2d5d3ab41ee01ccfe9d52dd6c9ee22b4971c
|
|
7
|
+
data.tar.gz: 1e68e920b3aa610ea1715c16e48e89060e8eba2b2511aafb3c7db0c44e21f8fac077529173befd9acf3afbe2962829cf4534dce371a2f6262628dd507f37469b
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.0.1] - 2026-09-18
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **RubyGems `documentation_uri`**: the published gem now points at the docs
|
|
13
|
+
site (`https://davidteren.github.io/mutineer/`) so gem-page discovery
|
|
14
|
+
reaches the Pages docs (#90).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **A red unmutated suite can no longer pass a mutation gate** — coverage
|
|
18
|
+
capture now keeps the original Minitest/RSpec result, and a failing clean run
|
|
19
|
+
aborts with the existing smoke-check error (exit 1) instead of scoring those
|
|
20
|
+
assertion failures as killed mutants. A warm coverage cache re-checks the
|
|
21
|
+
current suite and cannot bypass this (#96).
|
|
22
|
+
- **Concurrent external runs no longer restore each other's source files** —
|
|
23
|
+
swap and orphan recovery share one exclusive OS lock per source, acquired
|
|
24
|
+
before reading or healing. A live owner's mutant and backup stay intact; a
|
|
25
|
+
dead owner's backup still restores the original bytes (#99).
|
|
26
|
+
- **Coverage cache now invalidates when a required test helper changes** —
|
|
27
|
+
a successful map records fingerprints of project-local loaded Ruby files,
|
|
28
|
+
old cache entries without that data rebuild, and a helper-only edit no
|
|
29
|
+
longer hides a new survivor behind a stale 100% score (#97).
|
|
30
|
+
- **Release version calculation ignores floating major tags** — `release-pr.yml`
|
|
31
|
+
selects the newest complete `vMAJOR.MINOR.PATCH` ancestor and validates the
|
|
32
|
+
next version before writing files, so a later `v1` tag can no longer produce
|
|
33
|
+
`v1..1` (#95).
|
|
34
|
+
|
|
9
35
|
## [1.0.0] - 2026-09-08
|
|
10
36
|
|
|
11
37
|
The GitHub Action's PR default changes in this release, which is why it is a
|
|
@@ -381,6 +407,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
|
|
|
381
407
|
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
382
408
|
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
383
409
|
|
|
410
|
+
[1.0.1]: https://github.com/davidteren/mutineer/releases/tag/v1.0.1
|
|
384
411
|
[1.0.0]: https://github.com/davidteren/mutineer/releases/tag/v1.0.0
|
|
385
412
|
[0.11.4]: https://github.com/davidteren/mutineer/releases/tag/v0.11.4
|
|
386
413
|
[0.11.3]: https://github.com/davidteren/mutineer/releases/tag/v0.11.3
|
data/lib/mutineer/cli.rb
CHANGED
|
@@ -218,6 +218,11 @@ module Mutineer
|
|
|
218
218
|
# environment, not weak tests. Runtime error (exit 1), not usage (exit 2).
|
|
219
219
|
warn "mutineer: #{e.message}"
|
|
220
220
|
exit 1
|
|
221
|
+
rescue Mutineer::ConcurrentRunError => e
|
|
222
|
+
# Another process owns a source file. Runtime error (exit 1), not a
|
|
223
|
+
# backtrace: the working tree is still the other run's responsibility.
|
|
224
|
+
warn "mutineer: #{e.message}"
|
|
225
|
+
exit 1
|
|
221
226
|
rescue Mutineer::DaemonBootError => e
|
|
222
227
|
# The daemon is gone for good, so the run ended rather than scoring the rest
|
|
223
228
|
# against it. A deliberate stop deserves a message, not a raw backtrace.
|
|
@@ -22,7 +22,7 @@ module Mutineer
|
|
|
22
22
|
# Seconds per coverage subprocess before the parent kills it.
|
|
23
23
|
DEFAULT_CAPTURE_TIMEOUT = 120
|
|
24
24
|
|
|
25
|
-
attr_reader :project_root, :failed_test_files, :phase_a_ran, :map
|
|
25
|
+
attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
|
|
26
26
|
|
|
27
27
|
# Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
|
|
28
28
|
# map app-side and ships `map` + `failed_test_files` over IPC; the tool
|
|
@@ -33,11 +33,13 @@ module Mutineer
|
|
|
33
33
|
# @param map [Hash] the "file:line" => [test_files] map.
|
|
34
34
|
# @param failed_test_files [Array<String>] test files whose capture failed.
|
|
35
35
|
# @param project_root [String] project root (for path relativization).
|
|
36
|
+
# @param failed_clean_tests [Array<String>] test files whose unmutated run failed.
|
|
36
37
|
# @return [Mutineer::CoverageMap] a query-only map.
|
|
37
|
-
def self.from_data(map:, failed_test_files:, project_root:)
|
|
38
|
+
def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [])
|
|
38
39
|
instance = allocate
|
|
39
40
|
instance.instance_variable_set(:@map, map || {})
|
|
40
41
|
instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
|
|
42
|
+
instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
|
|
41
43
|
instance.instance_variable_set(:@project_root, project_root)
|
|
42
44
|
instance
|
|
43
45
|
end
|
|
@@ -57,6 +59,8 @@ module Mutineer
|
|
|
57
59
|
@verbose = verbose
|
|
58
60
|
@map = {}
|
|
59
61
|
@failed_test_files = []
|
|
62
|
+
@failed_clean_tests = []
|
|
63
|
+
@loaded_dependencies = {}
|
|
60
64
|
@phase_a_ran = false
|
|
61
65
|
end
|
|
62
66
|
|
|
@@ -75,7 +79,7 @@ module Mutineer
|
|
|
75
79
|
# never collides with a standalone one).
|
|
76
80
|
def build_via_fork(after_fork: nil)
|
|
77
81
|
warn_external_sources
|
|
78
|
-
cached_or { run_phase_a_via_fork(after_fork: after_fork) }
|
|
82
|
+
cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
# Lookup: the test files that cover `file:line`, or [] when none do.
|
|
@@ -141,18 +145,30 @@ module Mutineer
|
|
|
141
145
|
end
|
|
142
146
|
|
|
143
147
|
# Shared cache dance for both build paths: hit the digest-keyed cache, else
|
|
144
|
-
# yield to populate @map and persist it.
|
|
145
|
-
|
|
148
|
+
# yield to populate @map and persist it. A digest match is not proof that
|
|
149
|
+
# today's unmutated suite still passes — re-check on a cache hit.
|
|
150
|
+
#
|
|
151
|
+
# @param after_fork [Proc, nil] boot-mode fork hook forwarded to a clean re-check.
|
|
152
|
+
# @yield when the cache is missing or stale.
|
|
153
|
+
# @return [Mutineer::CoverageMap] self.
|
|
154
|
+
def cached_or(after_fork: nil)
|
|
146
155
|
@digest = compute_digest
|
|
147
156
|
cached = read_cache
|
|
148
|
-
if cached && cached["digest"] == @digest
|
|
157
|
+
if cached && cached["digest"] == @digest && dependencies_match?(cached)
|
|
149
158
|
@map = cached["map"] || {}
|
|
150
159
|
@failed_test_files = cached["failed_test_files"] || []
|
|
160
|
+
@failed_clean_tests = []
|
|
161
|
+
@loaded_dependencies = cached["dependencies"] || {}
|
|
162
|
+
retry_failed_captures(after_fork)
|
|
151
163
|
warn_incomplete unless @failed_test_files.empty?
|
|
164
|
+
verify_cached_clean(after_fork: after_fork)
|
|
165
|
+
verify_combined_clean(after_fork: after_fork)
|
|
166
|
+
save
|
|
152
167
|
return self
|
|
153
168
|
end
|
|
154
169
|
|
|
155
170
|
yield
|
|
171
|
+
verify_combined_clean(after_fork: after_fork)
|
|
156
172
|
save
|
|
157
173
|
self
|
|
158
174
|
end
|
|
@@ -164,12 +180,14 @@ module Mutineer
|
|
|
164
180
|
@phase_a_ran = true
|
|
165
181
|
@map = {}
|
|
166
182
|
@failed_test_files = []
|
|
183
|
+
@failed_clean_tests = []
|
|
184
|
+
@loaded_dependencies = {}
|
|
167
185
|
|
|
168
186
|
@test_paths.each do |test_path|
|
|
169
|
-
|
|
170
|
-
next unless
|
|
187
|
+
payload = capture(test_path)
|
|
188
|
+
next unless payload
|
|
171
189
|
|
|
172
|
-
|
|
190
|
+
accept_capture_payload(test_path, payload)
|
|
173
191
|
end
|
|
174
192
|
end
|
|
175
193
|
|
|
@@ -182,16 +200,18 @@ module Mutineer
|
|
|
182
200
|
@phase_a_ran = true
|
|
183
201
|
@map = {}
|
|
184
202
|
@failed_test_files = []
|
|
203
|
+
@failed_clean_tests = []
|
|
204
|
+
@loaded_dependencies = {}
|
|
185
205
|
abs_sources = abs_source_paths
|
|
186
206
|
|
|
187
207
|
@test_paths.each do |test_path|
|
|
188
|
-
# Tri-state payload: Hash =
|
|
189
|
-
# child, nil = pipe gone / empty. The String diagnostic is what
|
|
190
|
-
# an :uncapturable status.
|
|
191
|
-
case (
|
|
192
|
-
when Hash
|
|
208
|
+
# Tri-state payload: Hash = capture result, String = error diagnostic from
|
|
209
|
+
# the child, nil = pipe gone / empty. The String diagnostic is what
|
|
210
|
+
# becomes an :uncapturable status.
|
|
211
|
+
case (payload = fork_capture(absolute(test_path), abs_sources, after_fork))
|
|
212
|
+
when Hash then accept_capture_payload(test_path, payload)
|
|
193
213
|
when String
|
|
194
|
-
fail_test(test_path, @verbose ? "fork capture failed: #{
|
|
214
|
+
fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
|
|
195
215
|
"fork capture produced no result (re-run with --verbose for the error)")
|
|
196
216
|
else fail_test(test_path, "fork capture produced no result")
|
|
197
217
|
end
|
|
@@ -217,12 +237,14 @@ module Mutineer
|
|
|
217
237
|
# file needs neither Runner (Prism) nor Rails.
|
|
218
238
|
after_fork&.call
|
|
219
239
|
Coverage.result(clear: true, stop: false) # discard pre-test delta
|
|
220
|
-
TestRunners.for(@framework).run([abs_test])
|
|
240
|
+
passed = TestRunners.for(@framework).run([abs_test]).zero?
|
|
221
241
|
# lines:true yields {file => {lines: [...]}}; reduce to the counts
|
|
222
242
|
# array record() expects, keeping only our source files.
|
|
223
|
-
Coverage.result(stop: false)
|
|
224
|
-
|
|
225
|
-
|
|
243
|
+
coverage = Coverage.result(stop: false)
|
|
244
|
+
.select { |f, _| abs_sources.include?(f) }
|
|
245
|
+
.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
246
|
+
{ "passed" => passed, "coverage" => coverage,
|
|
247
|
+
"loaded_files" => capture_loaded_files }
|
|
226
248
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
227
249
|
# Stringify (an arbitrary Exception may not marshal); the parent
|
|
228
250
|
# surfaces this under --verbose. A String marshals safely over the pipe.
|
|
@@ -268,8 +290,8 @@ module Mutineer
|
|
|
268
290
|
|
|
269
291
|
# Spawns a fresh `ruby` reading an inline script from stdin. A fork would
|
|
270
292
|
# miss already-loaded app lines, so Coverage must start in a clean process
|
|
271
|
-
# before any source is loaded. Returns the
|
|
272
|
-
# nil when the subprocess failed (logged + skipped).
|
|
293
|
+
# before any source is loaded. Returns the wrapped capture payload
|
|
294
|
+
# (`passed` + `coverage`), or nil when the subprocess failed (logged + skipped).
|
|
273
295
|
def capture(test_path)
|
|
274
296
|
out = +""
|
|
275
297
|
status = nil
|
|
@@ -289,7 +311,10 @@ module Mutineer
|
|
|
289
311
|
end
|
|
290
312
|
return fail_test(test_path, "subprocess exited #{status.exitstatus}") unless status.success?
|
|
291
313
|
|
|
292
|
-
JSON.parse(out)
|
|
314
|
+
parsed = JSON.parse(out)
|
|
315
|
+
return fail_test(test_path, "invalid coverage output: missing pass/coverage payload") unless wrapped_capture?(parsed)
|
|
316
|
+
|
|
317
|
+
parsed
|
|
293
318
|
rescue JSON::ParserError => e
|
|
294
319
|
fail_test(test_path, "invalid coverage output: #{e.message}")
|
|
295
320
|
end
|
|
@@ -307,6 +332,241 @@ module Mutineer
|
|
|
307
332
|
nil
|
|
308
333
|
end
|
|
309
334
|
|
|
335
|
+
# True when `payload` is the wrapped capture JSON/Marshal contract
|
|
336
|
+
# (`passed` + `coverage`), not a raw Coverage.result hash.
|
|
337
|
+
#
|
|
338
|
+
# @api private
|
|
339
|
+
# @param payload [Object] parsed subprocess output or forked Marshal value.
|
|
340
|
+
# @return [Boolean]
|
|
341
|
+
def wrapped_capture?(payload)
|
|
342
|
+
payload.is_a?(Hash) && payload.key?("passed") && payload.key?("coverage")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Records a wrapped capture: assertion failures go to {#failed_clean_tests};
|
|
346
|
+
# successful coverage is inverted into the map. Capture crashes stay in
|
|
347
|
+
# {#failed_test_files} via {#fail_test}.
|
|
348
|
+
#
|
|
349
|
+
# @api private
|
|
350
|
+
# @param test_path [String] test file path.
|
|
351
|
+
# @param payload [Hash] wrapped capture with string keys.
|
|
352
|
+
# @return [void]
|
|
353
|
+
def accept_capture_payload(test_path, payload)
|
|
354
|
+
unless wrapped_capture?(payload)
|
|
355
|
+
fail_test(test_path, "invalid coverage output: missing pass/coverage payload")
|
|
356
|
+
return
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
record_loaded(payload["loaded_files"])
|
|
360
|
+
|
|
361
|
+
unless payload["passed"]
|
|
362
|
+
@failed_clean_tests << relativize(test_path)
|
|
363
|
+
return
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
coverage = payload["coverage"]
|
|
367
|
+
record(coverage, test_path) if coverage.is_a?(Hash)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Re-runs each successfully captured test on a cache hit. Digest equality
|
|
371
|
+
# cannot prove the current unmutated suite still passes.
|
|
372
|
+
#
|
|
373
|
+
# @api private
|
|
374
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
375
|
+
# @return [void]
|
|
376
|
+
def verify_cached_clean(after_fork: nil)
|
|
377
|
+
@test_paths.each do |test_path|
|
|
378
|
+
rel = relativize(test_path)
|
|
379
|
+
next if @failed_test_files.include?(rel)
|
|
380
|
+
|
|
381
|
+
ok = if @boot_path
|
|
382
|
+
fork_clean_pass?([absolute(test_path)], after_fork)
|
|
383
|
+
else
|
|
384
|
+
subprocess_clean_pass?([test_path])
|
|
385
|
+
end
|
|
386
|
+
@failed_clean_tests << rel unless ok
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Re-runs tests whose previous capture crashed. A fixed helper is invisible
|
|
391
|
+
# to the source/test digest when that capture never recorded `loaded_files`.
|
|
392
|
+
#
|
|
393
|
+
# @api private
|
|
394
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
395
|
+
# @return [void]
|
|
396
|
+
def retry_failed_captures(after_fork)
|
|
397
|
+
pending = @failed_test_files.dup
|
|
398
|
+
return if pending.empty?
|
|
399
|
+
|
|
400
|
+
@failed_test_files = []
|
|
401
|
+
abs_sources = abs_source_paths
|
|
402
|
+
pending.each do |rel|
|
|
403
|
+
test_path = @test_paths.find { |t| relativize(t) == rel } || rel
|
|
404
|
+
if @boot_path
|
|
405
|
+
payload = fork_capture(absolute(test_path), abs_sources, after_fork)
|
|
406
|
+
case payload
|
|
407
|
+
when Hash then accept_capture_payload(test_path, payload)
|
|
408
|
+
when String
|
|
409
|
+
fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
|
|
410
|
+
"fork capture produced no result (re-run with --verbose for the error)")
|
|
411
|
+
else fail_test(test_path, "fork capture produced no result")
|
|
412
|
+
end
|
|
413
|
+
else
|
|
414
|
+
payload = capture(test_path)
|
|
415
|
+
accept_capture_payload(test_path, payload) if payload
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Runs every successfully captured test together. Per-file capture can miss a
|
|
421
|
+
# failure that only appears when covering files share one process.
|
|
422
|
+
#
|
|
423
|
+
# @api private
|
|
424
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
425
|
+
# @return [void]
|
|
426
|
+
def verify_combined_clean(after_fork: nil)
|
|
427
|
+
runnable = @test_paths.reject { |t| @failed_test_files.include?(relativize(t)) }
|
|
428
|
+
return if runnable.size < 2
|
|
429
|
+
return unless @failed_clean_tests.empty?
|
|
430
|
+
|
|
431
|
+
ok = if @boot_path
|
|
432
|
+
fork_clean_pass?(runnable.map { |t| absolute(t) }, after_fork)
|
|
433
|
+
else
|
|
434
|
+
subprocess_clean_pass?(runnable)
|
|
435
|
+
end
|
|
436
|
+
@failed_clean_tests << "combined suite" unless ok
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Runs test files in a fresh interpreter and returns whether they passed.
|
|
440
|
+
#
|
|
441
|
+
# @api private
|
|
442
|
+
# @param test_paths [Array<String>] test file paths.
|
|
443
|
+
# @return [Boolean]
|
|
444
|
+
def subprocess_clean_pass?(test_paths)
|
|
445
|
+
status = nil
|
|
446
|
+
Open3.popen2(RbConfig.ruby, "-") do |stdin, stdout, wait_thr|
|
|
447
|
+
stdin.write(clean_check_script(test_paths))
|
|
448
|
+
stdin.close
|
|
449
|
+
reader = Thread.new { stdout.read }
|
|
450
|
+
unless wait_thr.join(@capture_timeout)
|
|
451
|
+
Process.kill(:KILL, wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
452
|
+
reader.kill
|
|
453
|
+
return false
|
|
454
|
+
end
|
|
455
|
+
reader.join
|
|
456
|
+
status = wait_thr.value
|
|
457
|
+
end
|
|
458
|
+
status&.success?
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Runs test files in a fork of the booted parent and returns whether they passed.
|
|
462
|
+
# Bounded by `@capture_timeout` so a hung child cannot block the CLI.
|
|
463
|
+
#
|
|
464
|
+
# @api private
|
|
465
|
+
# @param abs_tests [Array<String>] absolute test file paths.
|
|
466
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
467
|
+
# @return [Boolean]
|
|
468
|
+
def fork_clean_pass?(abs_tests, after_fork)
|
|
469
|
+
rd, wr = IO.pipe
|
|
470
|
+
rd.binmode
|
|
471
|
+
wr.binmode
|
|
472
|
+
pid = fork do
|
|
473
|
+
rd.close
|
|
474
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
475
|
+
begin
|
|
476
|
+
after_fork&.call
|
|
477
|
+
Coverage.result(clear: true, stop: false) if Coverage.running?
|
|
478
|
+
wr.write(Marshal.dump(TestRunners.for(@framework).run(abs_tests).zero?))
|
|
479
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
480
|
+
wr.write(Marshal.dump(false))
|
|
481
|
+
ensure
|
|
482
|
+
wr.close
|
|
483
|
+
exit!(0)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
wr.close
|
|
487
|
+
readable, = IO.select([rd], nil, nil, @capture_timeout)
|
|
488
|
+
unless readable
|
|
489
|
+
kill_fork_clean(pid)
|
|
490
|
+
rd.close
|
|
491
|
+
return false
|
|
492
|
+
end
|
|
493
|
+
data = rd.read
|
|
494
|
+
rd.close
|
|
495
|
+
Process.waitpid2(pid)
|
|
496
|
+
return false if data.empty?
|
|
497
|
+
|
|
498
|
+
Marshal.load(data)
|
|
499
|
+
rescue StandardError
|
|
500
|
+
false
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# SIGKILLs a hung clean-check child (and its group) then reaps it.
|
|
504
|
+
#
|
|
505
|
+
# @api private
|
|
506
|
+
# @param pid [Integer] child pid.
|
|
507
|
+
# @return [void]
|
|
508
|
+
def kill_fork_clean(pid)
|
|
509
|
+
begin
|
|
510
|
+
Process.kill(:KILL, -pid)
|
|
511
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
512
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
513
|
+
end
|
|
514
|
+
Process.waitpid2(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Builds a pass/fail-only subprocess script (no coverage instrumentation).
|
|
518
|
+
#
|
|
519
|
+
# @api private
|
|
520
|
+
# @param test_paths [Array<String>] test file paths.
|
|
521
|
+
# @return [String] Ruby script text.
|
|
522
|
+
def clean_check_script(test_paths)
|
|
523
|
+
@framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Minitest clean-suite check. Preloads configured sources like capture and
|
|
527
|
+
# standalone {Runner.execute}, so tests that rely on that preload stay green.
|
|
528
|
+
#
|
|
529
|
+
# @api private
|
|
530
|
+
# @param test_paths [Array<String>] test file paths.
|
|
531
|
+
# @return [String] Ruby script text.
|
|
532
|
+
def minitest_clean_check_script(test_paths)
|
|
533
|
+
loads = Array(test_paths).map { |t| "load #{absolute(t).inspect}" }.join("\n")
|
|
534
|
+
<<~RUBY
|
|
535
|
+
require "minitest"
|
|
536
|
+
require "stringio"
|
|
537
|
+
def Minitest.autorun; end
|
|
538
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
539
|
+
#{abs_source_paths.inspect}.each { |f| load f }
|
|
540
|
+
#{loads}
|
|
541
|
+
$stdout = StringIO.new
|
|
542
|
+
exit(Minitest.run([]) ? 0 : 1)
|
|
543
|
+
RUBY
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# RSpec clean-suite check. Preloads configured sources like capture.
|
|
547
|
+
#
|
|
548
|
+
# @api private
|
|
549
|
+
# @param test_paths [Array<String>] spec file paths.
|
|
550
|
+
# @return [String] Ruby script text.
|
|
551
|
+
def rspec_clean_check_script(test_paths)
|
|
552
|
+
specs = Array(test_paths).map { |t| absolute(t).inspect }.join(", ")
|
|
553
|
+
<<~RUBY
|
|
554
|
+
require "stringio"
|
|
555
|
+
begin
|
|
556
|
+
require "rspec/core"
|
|
557
|
+
rescue LoadError
|
|
558
|
+
exit 3
|
|
559
|
+
end
|
|
560
|
+
RSpec::Core::Runner.disable_autorun!
|
|
561
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
562
|
+
#{abs_source_paths.inspect}.each { |f| load f }
|
|
563
|
+
_sink = StringIO.new
|
|
564
|
+
$stdout = _sink
|
|
565
|
+
status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
|
|
566
|
+
exit(status.zero? ? 0 : 1)
|
|
567
|
+
RUBY
|
|
568
|
+
end
|
|
569
|
+
|
|
310
570
|
# Builds the framework-specific subprocess script.
|
|
311
571
|
#
|
|
312
572
|
# @api private
|
|
@@ -334,9 +594,10 @@ module Mutineer
|
|
|
334
594
|
load #{absolute(test_path).inspect}
|
|
335
595
|
_orig = $stdout
|
|
336
596
|
$stdout = StringIO.new
|
|
337
|
-
Minitest.run([])
|
|
597
|
+
_passed = Minitest.run([])
|
|
338
598
|
$stdout = _orig
|
|
339
|
-
puts Coverage.result
|
|
599
|
+
puts JSON.generate("passed" => _passed == true, "coverage" => Coverage.result,
|
|
600
|
+
"loaded_files" => #{loaded_files_expression})
|
|
340
601
|
RUBY
|
|
341
602
|
end
|
|
342
603
|
|
|
@@ -363,9 +624,10 @@ module Mutineer
|
|
|
363
624
|
_orig = $stdout
|
|
364
625
|
_sink = StringIO.new
|
|
365
626
|
$stdout = _sink
|
|
366
|
-
RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
|
|
627
|
+
_status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
|
|
367
628
|
$stdout = _orig
|
|
368
|
-
puts Coverage.result
|
|
629
|
+
puts JSON.generate("passed" => _status.zero?, "coverage" => Coverage.result,
|
|
630
|
+
"loaded_files" => #{loaded_files_expression})
|
|
369
631
|
RUBY
|
|
370
632
|
end
|
|
371
633
|
|
|
@@ -387,6 +649,101 @@ module Mutineer
|
|
|
387
649
|
end
|
|
388
650
|
end
|
|
389
651
|
|
|
652
|
+
# Ruby source of the child-side `$LOADED_FEATURES` filter (project `.rb` files).
|
|
653
|
+
#
|
|
654
|
+
# @api private
|
|
655
|
+
# @return [String] expression to embed in a capture subprocess script.
|
|
656
|
+
def loaded_files_expression
|
|
657
|
+
root = project_root_real
|
|
658
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
659
|
+
"begin; _root = #{prefix.inspect}; $LOADED_FEATURES.filter_map { |f| next unless f.end_with?(\".rb\"); abs = (File.realpath(f) rescue next); abs if abs.start_with?(_root) }; rescue StandardError; []; end"
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Canonical project root for loaded-feature matching (`/var` vs `/private/var`).
|
|
663
|
+
#
|
|
664
|
+
# @api private
|
|
665
|
+
# @return [String] realpath of the project root when it exists.
|
|
666
|
+
def project_root_real
|
|
667
|
+
File.realpath(File.expand_path(@project_root))
|
|
668
|
+
rescue Errno::ENOENT
|
|
669
|
+
File.expand_path(@project_root)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Project-local `.rb` files loaded in this process at capture time.
|
|
673
|
+
#
|
|
674
|
+
# @api private
|
|
675
|
+
# @return [Array<String>] absolute realpaths.
|
|
676
|
+
def capture_loaded_files
|
|
677
|
+
prefix = project_root_real
|
|
678
|
+
prefix = "#{prefix}/" unless prefix.end_with?("/")
|
|
679
|
+
$LOADED_FEATURES.filter_map do |f|
|
|
680
|
+
next unless f.end_with?(".rb")
|
|
681
|
+
|
|
682
|
+
abs = File.realpath(f)
|
|
683
|
+
abs if abs.start_with?(prefix)
|
|
684
|
+
rescue Errno::ENOENT
|
|
685
|
+
nil
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Fingerprints project-local support files from a capture payload.
|
|
690
|
+
#
|
|
691
|
+
# @api private
|
|
692
|
+
# @param paths [Array, nil] absolute loaded-file paths.
|
|
693
|
+
# @return [void]
|
|
694
|
+
def record_loaded(paths)
|
|
695
|
+
Array(paths).each do |raw|
|
|
696
|
+
next unless raw.is_a?(String) && File.file?(raw)
|
|
697
|
+
|
|
698
|
+
abs = File.realpath(raw)
|
|
699
|
+
rel = loaded_relative(abs)
|
|
700
|
+
next unless rel
|
|
701
|
+
next unless rel.end_with?(".rb")
|
|
702
|
+
next if rel.start_with?("vendor/bundle/") || rel.start_with?("node_modules/")
|
|
703
|
+
|
|
704
|
+
@loaded_dependencies[rel] = file_fingerprint(abs)
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Path of `abs` relative to the real project root, or nil when outside it.
|
|
709
|
+
#
|
|
710
|
+
# @api private
|
|
711
|
+
# @param abs [String] absolute realpath.
|
|
712
|
+
# @return [String, nil]
|
|
713
|
+
def loaded_relative(abs)
|
|
714
|
+
root = project_root_real
|
|
715
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
716
|
+
return unless abs.start_with?(prefix)
|
|
717
|
+
|
|
718
|
+
abs.delete_prefix(prefix)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Byte fingerprint of a file for cache dependency checks.
|
|
722
|
+
#
|
|
723
|
+
# @api private
|
|
724
|
+
# @param abs [String] absolute path.
|
|
725
|
+
# @return [String] hex digest.
|
|
726
|
+
def file_fingerprint(abs)
|
|
727
|
+
content = File.binread(abs)
|
|
728
|
+
Digest::SHA256.hexdigest("#{content.bytesize}\0#{content}")
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# True when the cached map recorded support-file fingerprints and they still
|
|
732
|
+
# match. Missing validity data is a miss (rebuild).
|
|
733
|
+
#
|
|
734
|
+
# @api private
|
|
735
|
+
# @param cached [Hash] parsed coverage.json.
|
|
736
|
+
# @return [Boolean]
|
|
737
|
+
def dependencies_match?(cached)
|
|
738
|
+
deps = cached["dependencies"]
|
|
739
|
+
return false unless deps.is_a?(Hash)
|
|
740
|
+
|
|
741
|
+
deps.all? do |rel, fingerprint|
|
|
742
|
+
abs = absolute(rel)
|
|
743
|
+
File.file?(abs) && file_fingerprint(abs) == fingerprint
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
390
747
|
# Digest each file's ROLE + relative path + content length + content, plus
|
|
391
748
|
# the load_paths. Without role/path/length delimiters the digest collides
|
|
392
749
|
# (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
|
|
@@ -462,8 +819,11 @@ module Mutineer
|
|
|
462
819
|
# @api private
|
|
463
820
|
# @return [void]
|
|
464
821
|
def save
|
|
822
|
+
return unless @failed_clean_tests.empty?
|
|
823
|
+
|
|
465
824
|
FileUtils.mkdir_p(@cache_dir)
|
|
466
|
-
data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
|
|
825
|
+
data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
|
|
826
|
+
"dependencies" => @loaded_dependencies, "map" => @map }
|
|
467
827
|
tmp = "#{cache_path}.tmp"
|
|
468
828
|
File.write(tmp, JSON.generate(data))
|
|
469
829
|
File.rename(tmp, cache_path) # atomic swap
|
|
@@ -494,9 +854,13 @@ module Mutineer
|
|
|
494
854
|
# @param path [String] path to relativize.
|
|
495
855
|
# @return [String] relative path.
|
|
496
856
|
def relativize(path)
|
|
497
|
-
|
|
857
|
+
abs = path.start_with?("/") ? path : absolute(path)
|
|
858
|
+
abs = realpath_if_exists(abs)
|
|
859
|
+
root = project_root_real
|
|
860
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
861
|
+
return abs unless abs.start_with?(prefix)
|
|
498
862
|
|
|
499
|
-
|
|
863
|
+
abs.delete_prefix(prefix)
|
|
500
864
|
end
|
|
501
865
|
|
|
502
866
|
# Expands a path relative to the project root.
|
|
@@ -505,7 +869,17 @@ module Mutineer
|
|
|
505
869
|
# @param path [String] path to expand.
|
|
506
870
|
# @return [String] absolute path.
|
|
507
871
|
def absolute(path)
|
|
508
|
-
File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
|
|
872
|
+
raw = File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
|
|
873
|
+
realpath_if_exists(raw)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Real path when the file exists, otherwise `path` unchanged.
|
|
877
|
+
#
|
|
878
|
+
# @api private
|
|
879
|
+
# @param path [String] absolute or relative path.
|
|
880
|
+
# @return [String]
|
|
881
|
+
def realpath_if_exists(path)
|
|
882
|
+
File.exist?(path) ? File.realpath(path) : path
|
|
509
883
|
end
|
|
510
884
|
end
|
|
511
885
|
end
|
|
@@ -105,6 +105,17 @@ module Mutineer
|
|
|
105
105
|
ensure
|
|
106
106
|
client.quit
|
|
107
107
|
end
|
|
108
|
+
# A red unmutated suite must abort, even when the shipped map is empty.
|
|
109
|
+
# Falling back to the full --test set would treat those failures as kills.
|
|
110
|
+
if data.is_a?(Hash) && Array(data["failed_clean_tests"]).any?
|
|
111
|
+
Runner.abort_if_unclean!(CoverageMap.from_data(
|
|
112
|
+
map: data["map"] || {},
|
|
113
|
+
failed_test_files: data["failed_test_files"] || [],
|
|
114
|
+
project_root: config.project_root,
|
|
115
|
+
failed_clean_tests: data["failed_clean_tests"]
|
|
116
|
+
))
|
|
117
|
+
end
|
|
118
|
+
|
|
108
119
|
unless data && !(data["map"] || {}).empty?
|
|
109
120
|
reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
|
|
110
121
|
warn_coverage_fallback(reason)
|
|
@@ -87,10 +87,13 @@ module Mutineer
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# Ask the daemon to build the coverage map app-side and return it. One-shot
|
|
90
|
-
# control message (no id).
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
90
|
+
# control message (no id). On success, returns
|
|
91
|
+
# `{"map"=>..., "failed_test_files"=>..., "failed_clean_tests"=>...}`.
|
|
92
|
+
# On coverage-build failure, returns
|
|
93
|
+
# `{"map"=>{}, "failed_test_files"=>[], "error"=>...}`.
|
|
94
|
+
# Returns nil if the daemon vanished. The caller then falls back to running
|
|
95
|
+
# the full test set (no narrowing) rather than mis-scoring, except a red
|
|
96
|
+
# unmutated suite which aborts.
|
|
94
97
|
#
|
|
95
98
|
# @return [Hash, nil] the coverage payload, or nil on a dead pipe.
|
|
96
99
|
def coverage
|
|
@@ -150,7 +150,8 @@ module Mutineer
|
|
|
150
150
|
load_paths: Array(@cfg["load_paths"]), project_root: root,
|
|
151
151
|
boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
|
|
152
152
|
).build_via_fork(after_fork: coverage_after_fork)
|
|
153
|
-
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files
|
|
153
|
+
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
|
|
154
|
+
"failed_clean_tests" => cmap.failed_clean_tests }
|
|
154
155
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
155
156
|
@errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
|
|
156
157
|
{ "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
|
data/lib/mutineer/file_swap.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
3
6
|
module Mutineer
|
|
4
|
-
# Raised when
|
|
5
|
-
#
|
|
6
|
-
# and unlocked). Aborting beats silently leaving the tree mutated.
|
|
7
|
+
# Raised when another process already holds exclusive ownership of a source
|
|
8
|
+
# file. Aborting beats silently restoring (or capturing) the other run's mutant.
|
|
7
9
|
class ConcurrentRunError < StandardError
|
|
8
|
-
def initialize(
|
|
9
|
-
super("
|
|
10
|
-
"in this directory? Aborting to avoid corrupting the source file.")
|
|
10
|
+
def initialize(path)
|
|
11
|
+
super("another mutineer run owns #{path} — aborting to avoid corrupting the source file.")
|
|
11
12
|
end
|
|
12
13
|
end
|
|
13
14
|
|
|
@@ -19,51 +20,92 @@ module Mutineer
|
|
|
19
20
|
#
|
|
20
21
|
# Defense in depth, mirroring the tempfile-orphan discipline
|
|
21
22
|
# (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
|
|
23
|
+
# - exclusive OS ownership (flock) is acquired before swap or recovery;
|
|
22
24
|
# - the original bytes are held in memory AND written to a sibling backup;
|
|
23
25
|
# - `ensure` restores from memory around every mutant;
|
|
24
26
|
# - the backup survives a SIGKILL (which skips `ensure`), so `restore_orphans`
|
|
25
|
-
# can self-heal a left-mutated tree on the next run's startup
|
|
27
|
+
# can self-heal a left-mutated tree on the next run's startup once the
|
|
28
|
+
# kernel has released the dead owner's lock.
|
|
26
29
|
# Only one mutant is in flight per file at a time (the external path is serial),
|
|
27
30
|
# so backups never collide.
|
|
28
31
|
module FileSwap
|
|
29
32
|
# Suffix for the on-disk backup; fixed so `restore_orphans` finds it.
|
|
30
33
|
BACKUP_SUFFIX = ".mutineer-backup"
|
|
31
34
|
|
|
35
|
+
# Subdirectory beside the source that holds flock files (stable across cwd
|
|
36
|
+
# and `cache_dir`, so concurrent runs on one inode share one lock).
|
|
37
|
+
LOCK_DIR_NAME = "file-swap-locks"
|
|
38
|
+
|
|
39
|
+
# Canonical source path => open lock File held by this process.
|
|
40
|
+
# @api private
|
|
41
|
+
OWNED = {}
|
|
42
|
+
|
|
43
|
+
# Real path when the file exists, otherwise `File.expand_path`. Symlink
|
|
44
|
+
# aliases of one inode share this identity for locks, backups, and ownership.
|
|
45
|
+
#
|
|
46
|
+
# @param path [String] source path, relative or absolute.
|
|
47
|
+
# @return [String] canonical absolute path.
|
|
48
|
+
def self.canonical_path(path)
|
|
49
|
+
expanded = File.expand_path(path)
|
|
50
|
+
File.exist?(expanded) ? File.realpath(expanded) : expanded
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Holds exclusive OS ownership of each source path for the duration of the
|
|
54
|
+
# block. Re-entrant for paths this process already owns. Raises
|
|
55
|
+
# {ConcurrentRunError} when another process holds a path (non-blocking).
|
|
56
|
+
#
|
|
57
|
+
# @param paths [Array<String>] source file paths to own.
|
|
58
|
+
# @yield the block to run while ownership is held.
|
|
59
|
+
# @return [Object] the block's return value.
|
|
60
|
+
def self.owning(paths)
|
|
61
|
+
acquired = []
|
|
62
|
+
Array(paths).map { |p| canonical_path(p) }.uniq.sort.each do |path|
|
|
63
|
+
next if OWNED.key?(path)
|
|
64
|
+
|
|
65
|
+
acquire!(path)
|
|
66
|
+
acquired << path
|
|
67
|
+
end
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
acquired.reverse_each { |path| release!(path) }
|
|
71
|
+
end
|
|
72
|
+
|
|
32
73
|
# Writes `mutated` to `source_file`, yields, then restores the original bytes
|
|
33
74
|
# on every exit path (normal return, exception, or `ensure`). Byte-exact:
|
|
34
75
|
# binary read/write preserves encoding, newlines, and trailing bytes.
|
|
76
|
+
# Acquires exclusive ownership first; a leftover backup with no live owner is
|
|
77
|
+
# treated as the original (a prior hard-killed run), not a concurrent run.
|
|
35
78
|
#
|
|
36
79
|
# @param source_file [String] path to the real source file.
|
|
37
80
|
# @param mutated [String] mutated source text to write for the duration.
|
|
38
81
|
# @yield the block to run while the mutant is on disk.
|
|
39
82
|
# @return [Object] the block's return value.
|
|
40
83
|
def self.with(source_file, mutated)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
File.binwrite(source_file, original)
|
|
59
|
-
File.unlink(backup) if File.exist?(backup)
|
|
84
|
+
path = canonical_path(source_file)
|
|
85
|
+
created = false
|
|
86
|
+
original = nil
|
|
87
|
+
backup = path + BACKUP_SUFFIX
|
|
88
|
+
owning([path]) do
|
|
89
|
+
begin
|
|
90
|
+
original = File.exist?(backup) ? File.binread(backup) : File.binread(path)
|
|
91
|
+
File.binwrite(backup, original)
|
|
92
|
+
created = true
|
|
93
|
+
File.binwrite(path, mutated)
|
|
94
|
+
yield
|
|
95
|
+
ensure
|
|
96
|
+
if created
|
|
97
|
+
File.binwrite(path, original)
|
|
98
|
+
File.unlink(backup) if File.exist?(backup)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
60
101
|
end
|
|
61
102
|
end
|
|
62
103
|
|
|
63
104
|
# Startup/after-run self-heal: restore any source file left mutated by a prior
|
|
64
105
|
# interrupted run (a leftover `*.mutineer-backup`), then remove the backup.
|
|
65
|
-
#
|
|
66
|
-
#
|
|
106
|
+
# Skips a backup whose source is owned by a live process. Prints one line to
|
|
107
|
+
# stderr when it actually heals something, so a developer knows their working
|
|
108
|
+
# tree was auto-restored (a file they did not touch).
|
|
67
109
|
#
|
|
68
110
|
# @param dirs [Array<String>] directories to sweep for orphaned backups.
|
|
69
111
|
# @return [void]
|
|
@@ -72,19 +114,10 @@ module Mutineer
|
|
|
72
114
|
dirs.uniq.each do |dir|
|
|
73
115
|
Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup|
|
|
74
116
|
source_file = backup.delete_suffix(BACKUP_SUFFIX)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# restore. Leave it untouched (never create a file from it).
|
|
117
|
+
begin
|
|
118
|
+
owning([source_file]) { healed += restore_one(backup, source_file) }
|
|
119
|
+
rescue ConcurrentRunError
|
|
79
120
|
next
|
|
80
|
-
elsif File.binread(source_file) == backup_bytes
|
|
81
|
-
# Redundant backup (e.g. a crash between restore and unlink): nothing
|
|
82
|
-
# to heal, just clear the orphan so the next run does not see a false race.
|
|
83
|
-
File.unlink(backup)
|
|
84
|
-
else
|
|
85
|
-
File.binwrite(source_file, backup_bytes)
|
|
86
|
-
File.unlink(backup)
|
|
87
|
-
healed += 1
|
|
88
121
|
end
|
|
89
122
|
end
|
|
90
123
|
end
|
|
@@ -92,5 +125,68 @@ module Mutineer
|
|
|
92
125
|
|
|
93
126
|
warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run."
|
|
94
127
|
end
|
|
128
|
+
|
|
129
|
+
# Exclusive non-blocking flock for canonical `path`.
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
# @param path [String] canonical source path.
|
|
133
|
+
# @return [void]
|
|
134
|
+
# @raise [Mutineer::ConcurrentRunError] when the lock is held elsewhere.
|
|
135
|
+
def self.acquire!(path)
|
|
136
|
+
file = File.open(lock_file(path), File::RDWR | File::CREAT, 0o644)
|
|
137
|
+
unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
138
|
+
file.close
|
|
139
|
+
raise ConcurrentRunError, path
|
|
140
|
+
end
|
|
141
|
+
OWNED[path] = file
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Flock path for a canonical source: `<dir>/.mutineer/file-swap-locks/<sha>`.
|
|
145
|
+
# Derived only from the source path so cwd and `cache_dir` cannot split a lock.
|
|
146
|
+
#
|
|
147
|
+
# @api private
|
|
148
|
+
# @param path [String] canonical source path.
|
|
149
|
+
# @return [String] lock file path.
|
|
150
|
+
def self.lock_file(path)
|
|
151
|
+
dir = File.join(File.dirname(path), ".mutineer", LOCK_DIR_NAME)
|
|
152
|
+
FileUtils.mkdir_p(dir)
|
|
153
|
+
File.join(dir, Digest::SHA256.hexdigest(path))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Releases a lock acquired by {acquire!}.
|
|
157
|
+
#
|
|
158
|
+
# @api private
|
|
159
|
+
# @param path [String] expanded source path.
|
|
160
|
+
# @return [void]
|
|
161
|
+
def self.release!(path)
|
|
162
|
+
file = OWNED.delete(path)
|
|
163
|
+
return unless file
|
|
164
|
+
|
|
165
|
+
file.flock(File::LOCK_UN)
|
|
166
|
+
file.close
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Restores one backup if the sibling source exists. Returns 1 when bytes
|
|
172
|
+
# were written back, 0 when the backup was redundant or had no sibling.
|
|
173
|
+
#
|
|
174
|
+
# @api private
|
|
175
|
+
# @param backup [String] path to the `*.mutineer-backup` file.
|
|
176
|
+
# @param source_file [String] corresponding source path.
|
|
177
|
+
# @return [Integer] 1 if healed, otherwise 0.
|
|
178
|
+
def self.restore_one(backup, source_file)
|
|
179
|
+
return 0 unless File.exist?(source_file)
|
|
180
|
+
|
|
181
|
+
backup_bytes = File.binread(backup)
|
|
182
|
+
if File.binread(source_file) == backup_bytes
|
|
183
|
+
File.unlink(backup)
|
|
184
|
+
0
|
|
185
|
+
else
|
|
186
|
+
File.binwrite(source_file, backup_bytes)
|
|
187
|
+
File.unlink(backup)
|
|
188
|
+
1
|
|
189
|
+
end
|
|
190
|
+
end
|
|
95
191
|
end
|
|
96
192
|
end
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -99,6 +99,7 @@ module Mutineer
|
|
|
99
99
|
load_paths: config.load_paths, framework: config.framework
|
|
100
100
|
).build_or_load
|
|
101
101
|
end
|
|
102
|
+
abort_if_unclean!(coverage_map)
|
|
102
103
|
|
|
103
104
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
104
105
|
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
@@ -184,44 +185,50 @@ module Mutineer
|
|
|
184
185
|
# @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
|
|
185
186
|
def self.execute_external(config, operator_classes)
|
|
186
187
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
187
|
-
|
|
188
|
+
sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
|
|
189
|
+
dirs = sources.map { |s| File.dirname(s) }.uniq
|
|
190
|
+
|
|
191
|
+
# Own every source before healing leftovers or reading bytes for mutation.
|
|
192
|
+
# A backup file alone is not ownership; flock is. Canonical paths so
|
|
193
|
+
# symlink aliases of one inode share one lock, independent of cache_dir.
|
|
194
|
+
FileSwap.owning(sources) do
|
|
195
|
+
# Heal any file a prior hard-killed run left mutated BEFORE reading source.
|
|
196
|
+
# collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
|
|
197
|
+
# still-mutated file would yield garbage offsets against the later-healed
|
|
198
|
+
# source. Heal first, then discover jobs from the clean tree.
|
|
199
|
+
FileSwap.restore_orphans(dirs)
|
|
188
200
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# still-mutated file would yield garbage offsets against the later-healed
|
|
192
|
-
# source. Heal first, then discover jobs from the clean tree.
|
|
193
|
-
FileSwap.restore_orphans(dirs)
|
|
201
|
+
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
202
|
+
jobs = filter_since(jobs, source_map, config) if config.since
|
|
194
203
|
|
|
195
|
-
|
|
196
|
-
|
|
204
|
+
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
205
|
+
# --test set to calibrate a timeout no mutant would use (#76).
|
|
206
|
+
next [AggregateResult.new(ignored_results), source_map] if jobs.empty?
|
|
207
|
+
|
|
208
|
+
# Calibrate the per-mutant timeout from the clean run (a real suite far
|
|
209
|
+
# outlasts the 10s in-process fork budget), and abort if it is not green.
|
|
210
|
+
# 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
|
|
211
|
+
# a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
|
|
212
|
+
# handful cannot stall a serial run for ~45min on a slow suite.
|
|
213
|
+
smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
|
|
214
|
+
timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
|
|
197
215
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
results = []
|
|
211
|
-
progress = Progress.new(jobs.size)
|
|
212
|
-
begin
|
|
213
|
-
jobs.each do |subject, mutation, id|
|
|
214
|
-
r = run_external(subject, mutation, config.test_command, abs_tests,
|
|
215
|
-
timeout: timeout, verbose: config.verbose)
|
|
216
|
-
results << r.with(subject: subject, mutation: mutation, id: id)
|
|
217
|
-
progress.tick
|
|
218
|
-
break if config.fail_fast && r.survived? # stop at the first survivor
|
|
216
|
+
results = []
|
|
217
|
+
progress = Progress.new(jobs.size)
|
|
218
|
+
begin
|
|
219
|
+
jobs.each do |subject, mutation, id|
|
|
220
|
+
r = run_external(subject, mutation, config.test_command, abs_tests,
|
|
221
|
+
timeout: timeout, verbose: config.verbose)
|
|
222
|
+
results << r.with(subject: subject, mutation: mutation, id: id)
|
|
223
|
+
progress.tick
|
|
224
|
+
break if config.fail_fast && r.survived? # stop at the first survivor
|
|
225
|
+
end
|
|
226
|
+
ensure
|
|
227
|
+
FileSwap.restore_orphans(dirs)
|
|
219
228
|
end
|
|
220
|
-
ensure
|
|
221
|
-
FileSwap.restore_orphans(dirs)
|
|
222
|
-
end
|
|
223
229
|
|
|
224
|
-
|
|
230
|
+
[AggregateResult.new(results + ignored_results), source_map]
|
|
231
|
+
end
|
|
225
232
|
end
|
|
226
233
|
|
|
227
234
|
# Runs one mutant through the external backend: apply the whole-file mutation
|
|
@@ -241,6 +248,21 @@ module Mutineer
|
|
|
241
248
|
end
|
|
242
249
|
end
|
|
243
250
|
|
|
251
|
+
# Aborts the run when coverage capture saw a red unmutated suite. Scoring
|
|
252
|
+
# those results would treat existing assertion failures as killed mutants.
|
|
253
|
+
#
|
|
254
|
+
# @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
|
|
255
|
+
# @return [void]
|
|
256
|
+
# @raise [Mutineer::SmokeCheckError] when any captured test failed clean.
|
|
257
|
+
def self.abort_if_unclean!(coverage_map)
|
|
258
|
+
files = coverage_map.failed_clean_tests
|
|
259
|
+
return if files.empty?
|
|
260
|
+
|
|
261
|
+
raise SmokeCheckError,
|
|
262
|
+
"the unmutated suite is not green (#{files.join(', ')}) — " \
|
|
263
|
+
"#{ExternalBackend.generic_env_hint}."
|
|
264
|
+
end
|
|
265
|
+
|
|
244
266
|
# Coverage-based test selection, shared by the in-process ({run}) and daemon
|
|
245
267
|
# paths so both narrow identically (score parity). Returns
|
|
246
268
|
# `[:run, abs_test_paths]` when some test covers the mutant's line, or
|
data/lib/mutineer/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutineer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -114,6 +114,7 @@ metadata:
|
|
|
114
114
|
source_code_uri: https://github.com/davidteren/mutineer
|
|
115
115
|
changelog_uri: https://github.com/davidteren/mutineer/blob/main/CHANGELOG.md
|
|
116
116
|
bug_tracker_uri: https://github.com/davidteren/mutineer/issues
|
|
117
|
+
documentation_uri: https://davidteren.github.io/mutineer/
|
|
117
118
|
rubygems_mfa_required: 'true'
|
|
118
119
|
rdoc_options: []
|
|
119
120
|
require_paths:
|