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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9104fa94a852cbd1257f9dce0af285865d98da1100849f5d1b8c5af12d6513fa
4
- data.tar.gz: 4ca6421fdd7ef6ef862813cbd74481ec6babf176991c8d34301368861ea19272
3
+ metadata.gz: a96e7bad1e1cf8d106957e770418e6a1854c677943114b9bc45421c4e06da7f2
4
+ data.tar.gz: c404d87071f3d270566e368ac70153f1e87c940eb170b220b972fb1b28b29889
5
5
  SHA512:
6
- metadata.gz: 66a7b0eae5bb41331d267b4c26a0b72e9023177d89d9d21cc679e712978836c4884b8b0347fd8263f55041897c2581e7117d5ae522965efbdb9af0daf5b47fee
7
- data.tar.gz: cf1ed699c65b733b717ce5ed6ee95f9f50387fb2963b0ecd5516fa43d6a18bd21e0c354f2c85bde2df56cec305446cd988697f8eb5ae5b080bdfd9d2edc0fa06
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
- 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)
@@ -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,14 @@
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
+ 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
- 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)
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
- # 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).
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
- 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).
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
@@ -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.1"
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.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: