mutineer 1.0.0 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9104fa94a852cbd1257f9dce0af285865d98da1100849f5d1b8c5af12d6513fa
4
- data.tar.gz: 4ca6421fdd7ef6ef862813cbd74481ec6babf176991c8d34301368861ea19272
3
+ metadata.gz: 77a6283314f6511b6ea8165477cda70f0e54bc5ba25d9e80b7afb95903d168b8
4
+ data.tar.gz: a7172bbd15af300e3064fa4df50a4845e4710d31c5458f6c535cb54472634a75
5
5
  SHA512:
6
- metadata.gz: 66a7b0eae5bb41331d267b4c26a0b72e9023177d89d9d21cc679e712978836c4884b8b0347fd8263f55041897c2581e7117d5ae522965efbdb9af0daf5b47fee
7
- data.tar.gz: cf1ed699c65b733b717ce5ed6ee95f9f50387fb2963b0ecd5516fa43d6a18bd21e0c354f2c85bde2df56cec305446cd988697f8eb5ae5b080bdfd9d2edc0fa06
6
+ metadata.gz: da4882d841268217538cd4e2f327efbc5630d0d198ff38636203cb304a4a5b219af9f317097c10cfa69fc85da4c8b55570add95ff5d36cfb7a23dcf4dc12b8d9
7
+ data.tar.gz: 7eee3c779742a39f0828027a96d1843e9dbd0843434eba9de07bdbf8528fbbf926828471253c38e05f4ef2af5a09bc3c966f11a0482685bfce560fdd3763f228
data/CHANGELOG.md CHANGED
@@ -6,6 +6,47 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.2] - 2026-09-21
10
+
11
+ ### Added
12
+ - **AI-readable docs wiring**: HTML pages with Markdown twins now advertise
13
+ `rel="alternate" type="text/markdown"`, `index.md` is the landing/CLI
14
+ essentials twin, `skill.md` is listed under Optional in `llms.txt`, and
15
+ `sitemap.xml` is generated from the same catalog as `llms.txt` (#91).
16
+ - **Single-source CLI contract**: exit codes and `--threshold` live in
17
+ `docs/fragments/contract.yml`; `rake docs:generate` writes `llms-full.txt`,
18
+ `json-schema.html`, and the marked copies so they cannot drift (#82).
19
+ - **YARD API on Pages**: the current gem's YARD HTML is published at
20
+ `/api/`, linked from the docs site, and `rake yard:pages:check` keeps it
21
+ from lagging the shipped sources. `documentation_uri` stays the Pages
22
+ root (#92).
23
+
24
+ ## [1.0.1] - 2026-09-18
25
+
26
+ ### Added
27
+ - **RubyGems `documentation_uri`**: the published gem now points at the docs
28
+ site (`https://davidteren.github.io/mutineer/`) so gem-page discovery
29
+ reaches the Pages docs (#90).
30
+
31
+ ### Fixed
32
+ - **A red unmutated suite can no longer pass a mutation gate** — coverage
33
+ capture now keeps the original Minitest/RSpec result, and a failing clean run
34
+ aborts with the existing smoke-check error (exit 1) instead of scoring those
35
+ assertion failures as killed mutants. A warm coverage cache re-checks the
36
+ current suite and cannot bypass this (#96).
37
+ - **Concurrent external runs no longer restore each other's source files** —
38
+ swap and orphan recovery share one exclusive OS lock per source, acquired
39
+ before reading or healing. A live owner's mutant and backup stay intact; a
40
+ dead owner's backup still restores the original bytes (#99).
41
+ - **Coverage cache now invalidates when a required test helper changes** —
42
+ a successful map records fingerprints of project-local loaded Ruby files,
43
+ old cache entries without that data rebuild, and a helper-only edit no
44
+ longer hides a new survivor behind a stale 100% score (#97).
45
+ - **Release version calculation ignores floating major tags** — `release-pr.yml`
46
+ selects the newest complete `vMAJOR.MINOR.PATCH` ancestor and validates the
47
+ next version before writing files, so a later `v1` tag can no longer produce
48
+ `v1..1` (#95).
49
+
9
50
  ## [1.0.0] - 2026-09-08
10
51
 
11
52
  The GitHub Action's PR default changes in this release, which is why it is a
@@ -381,6 +422,8 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
381
422
  - `.mutineer.yml` configuration (CLI > config > default precedence).
382
423
  - Byte-correct source handling for multibyte (UTF-8) sources.
383
424
 
425
+ [1.0.2]: https://github.com/davidteren/mutineer/releases/tag/v1.0.2
426
+ [1.0.1]: https://github.com/davidteren/mutineer/releases/tag/v1.0.1
384
427
  [1.0.0]: https://github.com/davidteren/mutineer/releases/tag/v1.0.0
385
428
  [0.11.4]: https://github.com/davidteren/mutineer/releases/tag/v0.11.4
386
429
  [0.11.3]: https://github.com/davidteren/mutineer/releases/tag/v0.11.3
data/README.md CHANGED
@@ -68,11 +68,13 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
68
68
 
69
69
  ### Exit codes
70
70
 
71
+ <!-- contract:exit-codes -->
71
72
  | Code | Meaning |
72
73
  |------|---------|
73
- | `0` | Score ≥ threshold (or no threshold set) |
74
- | `1` | Score below threshold, nothing could be scored and something broke, or more than one mutant produced no verdict and they exceed 10% of those attempted, a `--baseline` regression, or a runtime error |
75
- | `2` | Usage / invalid-flag error |
74
+ | `0` | Score ≥ threshold (or no gate) **and** no baseline regression. |
75
+ | `1` | Score below `--threshold`, OR nothing could be scored and something broke, or more than one mutant produced no verdict and they exceed 10% of those attempted, OR a `--baseline` regression, OR a runtime error. |
76
+ | `2` | Usage / invalid-flag error (mistyped flag, bad path, unreadable baseline). |
77
+ <!-- /contract:exit-codes -->
76
78
 
77
79
  ### Operators
78
80
 
@@ -273,6 +275,8 @@ structured exit codes, and diff-scoped runs. See:
273
275
  contract:
274
276
  [rendered](https://davidteren.github.io/mutineer/json-schema.html) ·
275
277
  [source](docs/json-schema.md)
278
+ - **Ruby API (YARD)** — class reference for the shipped gem:
279
+ [https://davidteren.github.io/mutineer/api/](https://davidteren.github.io/mutineer/api/)
276
280
 
277
281
  ## Configuration
278
282
 
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
- def cached_or
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
- coverage = capture(test_path)
170
- next unless coverage
187
+ payload = capture(test_path)
188
+ next unless payload
171
189
 
172
- record(coverage, test_path)
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 = coverage, String = error diagnostic from the
189
- # child, nil = pipe gone / empty. The String diagnostic is what becomes
190
- # an :uncapturable status.
191
- case (coverage = fork_capture(absolute(test_path), abs_sources, after_fork))
192
- when Hash then record(coverage, test_path)
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: #{coverage}" :
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
- .select { |f, _| abs_sources.include?(f) }
225
- .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
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 parsed Coverage.result hash, or
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.to_json
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.to_json
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, "map" => @map }
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
- return path unless path.start_with?("/")
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
- path.delete_prefix("#{@project_root}/")
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)
@@ -56,7 +56,7 @@ module Mutineer
56
56
  # this mutant. Never a wrong verdict, never a wedged run.
57
57
  #
58
58
  # @param id [Integer] request id (echoed back for ordering safety).
59
- # @param payload [Hash] {"code" => mutated ruby, "source_file" => path}.
59
+ # @param payload [Hash] mutated ruby under the "code" key, path under "source_file".
60
60
  # @param tests [Array<String>] covering test file paths.
61
61
  # @param timeout [Numeric] per-mutant wall-clock timeout (seconds).
62
62
  # @param worker [Integer] worker slot; the daemon routes the fork to
@@ -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). Returns `{"map"=>..., "failed_test_files"=>...}`
91
- # (possibly with an `"error"`), or nil if the daemon vanished. The caller then
92
- # falls back to running the full test set (no narrowing) rather than
93
- # mis-scoring.
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}" }
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "fileutils"
5
+
3
6
  module Mutineer
4
- # Raised when a source file's backup already exists as FileSwap.with begins.
5
- # A second mutineer run is racing on the same file (the backup path is shared
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(backup)
9
- super("a backup already exists at #{backup} is another mutineer run active " \
10
- "in this directory? Aborting to avoid corrupting the source file.")
10
+ # @param path [String] the source file the other run already owns.
11
+ # @return [ConcurrentRunError]
12
+ def initialize(path)
13
+ super("another mutineer run owns #{path} — aborting to avoid corrupting the source file.")
11
14
  end
12
15
  end
13
16
 
@@ -19,51 +22,92 @@ module Mutineer
19
22
  #
20
23
  # Defense in depth, mirroring the tempfile-orphan discipline
21
24
  # (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
25
+ # - exclusive OS ownership (flock) is acquired before swap or recovery;
22
26
  # - the original bytes are held in memory AND written to a sibling backup;
23
27
  # - `ensure` restores from memory around every mutant;
24
28
  # - 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.
29
+ # can self-heal a left-mutated tree on the next run's startup once the
30
+ # kernel has released the dead owner's lock.
26
31
  # Only one mutant is in flight per file at a time (the external path is serial),
27
32
  # so backups never collide.
28
33
  module FileSwap
29
34
  # Suffix for the on-disk backup; fixed so `restore_orphans` finds it.
30
35
  BACKUP_SUFFIX = ".mutineer-backup"
31
36
 
37
+ # Subdirectory beside the source that holds flock files (stable across cwd
38
+ # and `cache_dir`, so concurrent runs on one inode share one lock).
39
+ LOCK_DIR_NAME = "file-swap-locks"
40
+
41
+ # Canonical source path => open lock File held by this process.
42
+ # @api private
43
+ OWNED = {}
44
+
45
+ # Real path when the file exists, otherwise `File.expand_path`. Symlink
46
+ # aliases of one inode share this identity for locks, backups, and ownership.
47
+ #
48
+ # @param path [String] source path, relative or absolute.
49
+ # @return [String] canonical absolute path.
50
+ def self.canonical_path(path)
51
+ expanded = File.expand_path(path)
52
+ File.exist?(expanded) ? File.realpath(expanded) : expanded
53
+ end
54
+
55
+ # Holds exclusive OS ownership of each source path for the duration of the
56
+ # block. Re-entrant for paths this process already owns. Raises
57
+ # {ConcurrentRunError} when another process holds a path (non-blocking).
58
+ #
59
+ # @param paths [Array<String>] source file paths to own.
60
+ # @yield the block to run while ownership is held.
61
+ # @return [Object] the block's return value.
62
+ def self.owning(paths)
63
+ acquired = []
64
+ Array(paths).map { |p| canonical_path(p) }.uniq.sort.each do |path|
65
+ next if OWNED.key?(path)
66
+
67
+ acquire!(path)
68
+ acquired << path
69
+ end
70
+ yield
71
+ ensure
72
+ acquired.reverse_each { |path| release!(path) }
73
+ end
74
+
32
75
  # Writes `mutated` to `source_file`, yields, then restores the original bytes
33
76
  # on every exit path (normal return, exception, or `ensure`). Byte-exact:
34
77
  # binary read/write preserves encoding, newlines, and trailing bytes.
78
+ # Acquires exclusive ownership first; a leftover backup with no live owner is
79
+ # treated as the original (a prior hard-killed run), not a concurrent run.
35
80
  #
36
81
  # @param source_file [String] path to the real source file.
37
82
  # @param mutated [String] mutated source text to write for the duration.
38
83
  # @yield the block to run while the mutant is on disk.
39
84
  # @return [Object] the block's return value.
40
85
  def self.with(source_file, mutated)
41
- backup = source_file + BACKUP_SUFFIX
42
- # A backup already on disk means either a prior hard-killed run
43
- # (restore_orphans should have healed it at startup) or a SECOND mutineer
44
- # run racing us on the same file. The backup path is shared and unlocked, so
45
- # proceeding would let us capture the other run's mutant AS the "original"
46
- # and permanently mutate the tree. Refuse loudly rather than silently
47
- # corrupt, and do it BEFORE `created` is set, so the ensure below never
48
- # touches a backup we don't own.
49
- raise ConcurrentRunError, backup if File.exist?(backup)
50
-
51
- original = File.binread(source_file)
52
- File.binwrite(backup, original)
53
- created = true
54
- File.binwrite(source_file, mutated)
55
- yield
56
- ensure
57
- if created
58
- File.binwrite(source_file, original)
59
- File.unlink(backup) if File.exist?(backup)
86
+ path = canonical_path(source_file)
87
+ created = false
88
+ original = nil
89
+ backup = path + BACKUP_SUFFIX
90
+ owning([path]) do
91
+ begin
92
+ original = File.exist?(backup) ? File.binread(backup) : File.binread(path)
93
+ File.binwrite(backup, original)
94
+ created = true
95
+ File.binwrite(path, mutated)
96
+ yield
97
+ ensure
98
+ if created
99
+ File.binwrite(path, original)
100
+ File.unlink(backup) if File.exist?(backup)
101
+ end
102
+ end
60
103
  end
61
104
  end
62
105
 
63
106
  # Startup/after-run self-heal: restore any source file left mutated by a prior
64
107
  # interrupted run (a leftover `*.mutineer-backup`), then remove the backup.
65
- # Prints one line to stderr when it actually heals something, so a developer
66
- # knows their working tree was auto-restored (a file they did not touch).
108
+ # Skips a backup whose source is owned by a live process. Prints one line to
109
+ # stderr when it actually heals something, so a developer knows their working
110
+ # tree was auto-restored (a file they did not touch).
67
111
  #
68
112
  # @param dirs [Array<String>] directories to sweep for orphaned backups.
69
113
  # @return [void]
@@ -72,19 +116,10 @@ module Mutineer
72
116
  dirs.uniq.each do |dir|
73
117
  Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup|
74
118
  source_file = backup.delete_suffix(BACKUP_SUFFIX)
75
- backup_bytes = File.binread(backup)
76
- if !File.exist?(source_file)
77
- # A real user file that merely ends in our suffix, with no sibling to
78
- # restore. Leave it untouched (never create a file from it).
119
+ begin
120
+ owning([source_file]) { healed += restore_one(backup, source_file) }
121
+ rescue ConcurrentRunError
79
122
  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
123
  end
89
124
  end
90
125
  end
@@ -92,5 +127,68 @@ module Mutineer
92
127
 
93
128
  warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run."
94
129
  end
130
+
131
+ # Exclusive non-blocking flock for canonical `path`.
132
+ #
133
+ # @api private
134
+ # @param path [String] canonical source path.
135
+ # @return [void]
136
+ # @raise [Mutineer::ConcurrentRunError] when the lock is held elsewhere.
137
+ def self.acquire!(path)
138
+ file = File.open(lock_file(path), File::RDWR | File::CREAT, 0o644)
139
+ unless file.flock(File::LOCK_EX | File::LOCK_NB)
140
+ file.close
141
+ raise ConcurrentRunError, path
142
+ end
143
+ OWNED[path] = file
144
+ end
145
+
146
+ # Flock path for a canonical source: `<dir>/.mutineer/file-swap-locks/<sha>`.
147
+ # Derived only from the source path so cwd and `cache_dir` cannot split a lock.
148
+ #
149
+ # @api private
150
+ # @param path [String] canonical source path.
151
+ # @return [String] lock file path.
152
+ def self.lock_file(path)
153
+ dir = File.join(File.dirname(path), ".mutineer", LOCK_DIR_NAME)
154
+ FileUtils.mkdir_p(dir)
155
+ File.join(dir, Digest::SHA256.hexdigest(path))
156
+ end
157
+
158
+ # Releases a lock acquired by {acquire!}.
159
+ #
160
+ # @api private
161
+ # @param path [String] expanded source path.
162
+ # @return [void]
163
+ def self.release!(path)
164
+ file = OWNED.delete(path)
165
+ return unless file
166
+
167
+ file.flock(File::LOCK_UN)
168
+ file.close
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # Restores one backup if the sibling source exists. Returns 1 when bytes
174
+ # were written back, 0 when the backup was redundant or had no sibling.
175
+ #
176
+ # @api private
177
+ # @param backup [String] path to the `*.mutineer-backup` file.
178
+ # @param source_file [String] corresponding source path.
179
+ # @return [Integer] 1 if healed, otherwise 0.
180
+ def self.restore_one(backup, source_file)
181
+ return 0 unless File.exist?(source_file)
182
+
183
+ backup_bytes = File.binread(backup)
184
+ if File.binread(source_file) == backup_bytes
185
+ File.unlink(backup)
186
+ 0
187
+ else
188
+ File.binwrite(source_file, backup_bytes)
189
+ File.unlink(backup)
190
+ 1
191
+ end
192
+ end
95
193
  end
96
194
  end
@@ -10,14 +10,16 @@ module Mutineer
10
10
  # Never call this in the parent — it manipulates global Minitest state
11
11
  # (autorun, runnables) that only makes sense in a throwaway forked child.
12
12
  #
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
- # create a second exit-2 path and break this method's 0/1 return contract.
13
+ # A missing minitest is rescued as FrameworkUnavailable (Isolation.run
14
+ # still turns that raise into exit 2). There is no rescue around the
15
+ # suite run itself: Isolation.run's fork block is the single exception
16
+ # boundary for unexpected errors. Swallowing those here would create a
17
+ # second exit-2 path and break this method's 0/1 return contract.
16
18
  class MinitestIntegration
17
- # ponytail: tested via runner_test.rb (U6), not in isolation — a direct
18
- # unit test would require forking and duplicate isolation_test's coverage.
19
+ # Tested via runner_test.rb, not in isolation — a direct unit test
20
+ # would require forking and duplicate isolation_test's coverage.
19
21
  #
20
- # `test_files` is one path or an Array of paths (M3 coverage selection
22
+ # `test_files` is one path or an Array of paths (coverage selection
21
23
  # passes the covering subset); each is loaded before the single
22
24
  # Minitest.run.
23
25
  #
@@ -19,7 +19,7 @@ module Mutineer
19
19
  # Computes the stable id for a single mutant.
20
20
  #
21
21
  # NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
22
- # with the separator; SHA256[0,12] gives a fixed-length, copy-pasteable key.
22
+ # with the separator; `SHA256[0,12]` gives a fixed-length, copy-pasteable key.
23
23
  #
24
24
  # @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
25
25
  # its `qualified_name` anchors the id to a method rather than a byte position.
@@ -15,12 +15,11 @@ require_relative "mutators/collection_method"
15
15
  module Mutineer
16
16
  # Maps operator names to operator classes.
17
17
  #
18
- # DEFAULT_NAMES is the v1 default set
19
- # (the M4 Tier-1 + statement-removal operators per locked decision #2). The
20
- # three Tier-2 operators live in ALL but are OFF by default — they only run
21
- # when named via --operators or `operators:` in .mutineer.yml (KTD8). Keeping
22
- # DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps the M4 default
23
- # survivor set unchanged.
18
+ # DEFAULT_NAMES is the v1 default set (Tier-1 plus statement-removal).
19
+ # The six Tier-2 operators live in ALL but are OFF by default — they only
20
+ # run when named via `--operators` or `operators:` in `.mutineer.yml`.
21
+ # Keeping DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps
22
+ # the default survivor set unchanged.
24
23
  class MutatorRegistry
25
24
  # All available mutator classes keyed by operator name.
26
25
  ALL = {
@@ -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
- dirs = source_dirs(config)
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
- # Heal any file a prior hard-killed run left mutated BEFORE reading source.
190
- # collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
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
- jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
196
- jobs = filter_since(jobs, source_map, config) if config.since
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
- # Nothing to mutate: return before the smoke check, which runs the whole
199
- # --test set to calibrate a timeout no mutant would use (#76).
200
- return [AggregateResult.new(ignored_results), source_map] if jobs.empty?
201
-
202
- # Calibrate the per-mutant timeout from the clean run (a real suite far
203
- # outlasts the 10s in-process fork budget), and abort if it is not green.
204
- # 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
205
- # a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
206
- # handful cannot stall a serial run for ~45min on a slow suite.
207
- smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
208
- timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
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
- [AggregateResult.new(results + ignored_results), source_map]
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.2"
6
6
  end
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.0
4
+ version: 1.0.2
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: