mutineer 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4020e2cd0e53ac4f406998d2c251d26abee9f75c4e6ceef365f2ffe565be50ab
4
- data.tar.gz: f42dbc71077fc802b56f878e27885568f3d128f244875fb678dede5f6940f385
3
+ metadata.gz: 51400ef0019b1a18f5c4b548e7789d072cbd744bd7918aa402de83052b37d5b6
4
+ data.tar.gz: 31d84ebaf51dbed8a5fc9273aece37f2ea288af9e41efd8006c939439c318125
5
5
  SHA512:
6
- metadata.gz: dc24bb60419b7d701a85a09c70495c9d9d7ce2cd2d5b5a10621cb03a940276c3526dc650ee4deb7f93529e09b67b872777c2483419979d5740a83aa9ac9bfd26
7
- data.tar.gz: ff59317a64ab0e3478c7dde381ac9752b56c83920d1a63bcf6528ac826f286d787b2d560c1946e897790bd47325ac2209d22b537e3baa146b90f2bedcd2f0c72
6
+ metadata.gz: 3907408aad53671313f88203fefc9da64f6ee76bffc57b7754ed13b31f19f7a0311c61f6d7a074a21eea78ab6bd74d3143d51e3b25fa9a03eba49901b71c3d5f
7
+ data.tar.gz: e67bfedd8a82c97496e9ab2189c390826cc6e55678c6c88ab4f40df0b784f92237986e9651668c512839d9803d0f921308228722e2552f6886ead65597d7acfb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.3.0] - 2026-06-28
8
+
9
+ ### Added
10
+ - **Coverage-guided test selection in boot mode** (#1) — `--rails`/`--boot` now
11
+ captures coverage by forking the booted app and runs only the test files that
12
+ cover each mutant's line (uncovered lines report `no_coverage`), instead of
13
+ running every `--test` file for every mutant. Cached like standalone mode.
14
+
7
15
  ## [0.2.0] - 2026-06-28
8
16
 
9
17
  ### Added
@@ -38,5 +46,6 @@ All notable changes to this project are documented here. The format is based on
38
46
  - `.mutineer.yml` configuration (CLI > config > default precedence).
39
47
  - Byte-correct source handling for multibyte (UTF-8) sources.
40
48
 
49
+ [0.3.0]: https://github.com/davidteren/mutineer/releases/tag/v0.3.0
41
50
  [0.2.0]: https://github.com/davidteren/mutineer/releases/tag/v0.2.0
42
51
  [0.1.0]: https://github.com/davidteren/mutineer/releases/tag/v0.1.0
data/README.md CHANGED
@@ -82,9 +82,9 @@ RAILS_ENV=test bundle exec mutineer run \
82
82
  then forks and inherits it), defaults `--strategy` to `redefine` (surgical — it
83
83
  avoids reloading files into the app tree), and reconnects ActiveRecord in each
84
84
  fork so the database connection is fork-safe. Use `--boot FILE` to boot a
85
- different entry point. Boot mode requires at least one `--test` file and runs
86
- those tests for every mutant (coverage-guided selection is not yet available in
87
- boot mode).
85
+ different entry point. Boot mode requires at least one `--test` file and is
86
+ coverage-guided each mutant runs only the test files that exercise its line
87
+ (coverage is captured by forking the booted app, then cached).
88
88
 
89
89
  Add Mutineer to your Gemfile's test group:
90
90
 
@@ -5,6 +5,8 @@ require "json"
5
5
  require "digest"
6
6
  require "fileutils"
7
7
  require "rbconfig"
8
+ require "coverage"
9
+ require_relative "minitest_integration"
8
10
 
9
11
  module Mutineer
10
12
  # Maps `(source_file, line) -> [test_files]` so each mutant runs only against
@@ -21,24 +23,50 @@ module Mutineer
21
23
 
22
24
  def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
23
25
  load_paths: ["lib"], project_root: Dir.pwd,
24
- capture_timeout: DEFAULT_CAPTURE_TIMEOUT)
26
+ capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil)
25
27
  @source_paths = Array(source_paths)
26
28
  @test_paths = Array(test_paths)
27
29
  @cache_dir = cache_dir
28
30
  @load_paths = Array(load_paths)
29
31
  @project_root = project_root
30
32
  @capture_timeout = capture_timeout
33
+ @boot_path = boot_path
31
34
  @map = {}
32
35
  @failed_test_files = []
33
36
  @phase_a_ran = false
34
37
  end
35
38
 
36
- # Phase A entry point: load the cached map when the content digest matches,
37
- # otherwise rebuild from subprocesses and overwrite the cache.
39
+ # Phase A entry point (standalone): load the cached map when the content
40
+ # digest matches, otherwise rebuild from subprocesses and overwrite the cache.
38
41
  def build_or_load
39
42
  warn_external_sources
40
- @digest = compute_digest
43
+ cached_or { run_phase_a }
44
+ end
45
+
46
+ # Boot-mode Phase A: Coverage is already running in the parent (started before
47
+ # the app booted, so booted source lines are instrumented). A clean `ruby`
48
+ # subprocess has no booted env, so per-test coverage is captured by FORKING
49
+ # the booted parent instead. Inverts into the same map #tests_for reads, and
50
+ # reuses the digest cache (the digest mixes in the boot file so a boot cache
51
+ # never collides with a standalone one).
52
+ def build_via_fork(rails: false)
53
+ warn_external_sources
54
+ cached_or { run_phase_a_via_fork(rails: rails) }
55
+ end
41
56
 
57
+ # Phase B lookup: the test files that cover `file:line`, or [] when none do.
58
+ # ponytail: per-file granularity; upgrade to per-method when throughput
59
+ # warrants (requires Minitest method isolation + finer Coverage tracking).
60
+ def tests_for(file, line)
61
+ @map["#{relativize(file)}:#{line}"] || []
62
+ end
63
+
64
+ private
65
+
66
+ # Shared cache dance for both build paths: hit the digest-keyed cache, else
67
+ # yield to populate @map and persist it.
68
+ def cached_or
69
+ @digest = compute_digest
42
70
  cached = read_cache
43
71
  if cached && cached["digest"] == @digest
44
72
  @map = cached["map"] || {}
@@ -47,20 +75,11 @@ module Mutineer
47
75
  return self
48
76
  end
49
77
 
50
- run_phase_a
78
+ yield
51
79
  save
52
80
  self
53
81
  end
54
82
 
55
- # Phase B lookup: the test files that cover `file:line`, or [] when none do.
56
- # ponytail: per-file granularity; upgrade to per-method when throughput
57
- # warrants (requires Minitest method isolation + finer Coverage tracking).
58
- def tests_for(file, line)
59
- @map["#{relativize(file)}:#{line}"] || []
60
- end
61
-
62
- private
63
-
64
83
  def run_phase_a
65
84
  @phase_a_ran = true
66
85
  @map = {}
@@ -74,6 +93,65 @@ module Mutineer
74
93
  end
75
94
  end
76
95
 
96
+ # Boot-mode Phase A. For each test file, fork the booted parent; the child
97
+ # resets its Coverage delta, runs that ONE test, and marshals back the raw
98
+ # per-source coverage counts. record() inverts them exactly as the subprocess
99
+ # path does. ponytail: serial fork (one test at a time) — boot apps fork
100
+ # cheaply via COW and per-test isolation matters more than throughput here.
101
+ def run_phase_a_via_fork(rails:)
102
+ @phase_a_ran = true
103
+ @map = {}
104
+ @failed_test_files = []
105
+ abs_sources = abs_source_paths
106
+
107
+ @test_paths.each do |test_path|
108
+ coverage = fork_capture(absolute(test_path), abs_sources, rails)
109
+ next fail_test(test_path, "fork capture produced no result") unless coverage
110
+
111
+ record(coverage, test_path)
112
+ end
113
+ end
114
+
115
+ # Fork the booted parent, run one test under the inherited Coverage, and
116
+ # return its per-source counts hash (or nil on failure). Reuses the same
117
+ # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
118
+ def fork_capture(abs_test, abs_sources, rails)
119
+ rd, wr = IO.pipe
120
+ pid = fork do
121
+ rd.close
122
+ payload =
123
+ begin
124
+ Runner.send(:reconnect_active_record) if rails
125
+ Coverage.result(clear: true, stop: false) # discard pre-test delta
126
+ MinitestIntegration.run([abs_test])
127
+ # lines:true yields {file => {lines: [...]}}; reduce to the counts
128
+ # array record() expects, keeping only our source files.
129
+ Coverage.result(stop: false)
130
+ .select { |f, _| abs_sources.include?(f) }
131
+ .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
132
+ rescue Exception # rubocop:disable Lint/RescueException
133
+ nil
134
+ end
135
+ begin
136
+ wr.write(Marshal.dump(payload))
137
+ rescue StandardError # rubocop:disable Lint/SuppressedException
138
+ # pipe gone; parent records "no result"
139
+ ensure
140
+ wr.close
141
+ exit!(0) # skip at_exit so the parent suite's autorun never re-fires here
142
+ end
143
+ end
144
+ wr.close
145
+ data = rd.read
146
+ rd.close
147
+ Process.waitpid(pid)
148
+ return nil if data.empty?
149
+
150
+ Marshal.load(data)
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
77
155
  # Spawns a fresh `ruby` reading an inline script from stdin. A fork would
78
156
  # miss already-loaded app lines, so Coverage must start in a clean process
79
157
  # before any source is loaded (KTD1/KTD2). Returns the parsed Coverage.result
@@ -154,10 +232,17 @@ module Mutineer
154
232
  d = Digest::SHA256.new
155
233
  digest_group(d, "source", @source_paths)
156
234
  digest_group(d, "test", @test_paths)
235
+ digest_group(d, "boot", [boot_digest_path]) if @boot_path
157
236
  @load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
158
237
  d.hexdigest
159
238
  end
160
239
 
240
+ # boot_path is a require-style path (e.g. "config/environment", no extension);
241
+ # resolve it to the real file for reading, appending ".rb" when needed.
242
+ def boot_digest_path
243
+ File.exist?(absolute(@boot_path)) ? @boot_path : "#{@boot_path}.rb"
244
+ end
245
+
161
246
  def digest_group(digest, role, paths)
162
247
  paths.sort.each do |p|
163
248
  content = File.read(absolute(p))
@@ -38,29 +38,39 @@ module Mutineer
38
38
  # parse that needs nothing loaded. Standalone mode requires the sources as
39
39
  # before so their classes exist for the children to inherit.
40
40
  if config.boot
41
+ # Coverage instruments only files loaded AFTER it starts. Start it BEFORE
42
+ # the boot require so the entire app loaded during boot is instrumented;
43
+ # forked children then measure each test's coverage delta against it.
44
+ require "coverage"
45
+ Coverage.start(lines: true) unless Coverage.running?
41
46
  require File.expand_path(config.boot, config.project_root)
42
47
  else
43
48
  config.sources.each { |f| require File.expand_path(f, config.project_root) }
44
49
  end
45
50
  config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
46
51
 
47
- # Boot mode skips coverage selection entirely: every mutant runs the given
48
- # --test files (precomputed absolute here, used directly by Runner.run).
49
52
  if config.boot
50
- coverage_map = nil
51
- boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
52
53
  # Rails/Minitest test files do `require "test_helper"`, which needs the
53
54
  # test root on $LOAD_PATH (`bin/rails test` adds it). Prepend each test
54
55
  # file's helper root here in the parent so loading them in the fork
55
- # children resolves. Inherited by every fork.
56
+ # children (both coverage capture and per-mutant) resolves.
57
+ boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
56
58
  test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
59
+
60
+ # Boot mode now uses coverage selection too: capture each test's coverage
61
+ # by forking the booted parent, then select covering tests per mutant.
62
+ coverage_map = CoverageMap.new(
63
+ source_paths: config.sources, test_paths: config.tests,
64
+ cache_dir: config.cache_dir, project_root: config.project_root,
65
+ load_paths: config.load_paths,
66
+ boot_path: File.expand_path(config.boot, config.project_root)
67
+ ).build_via_fork(rails: config.rails)
57
68
  else
58
69
  coverage_map = CoverageMap.new(
59
70
  source_paths: config.sources, test_paths: config.tests,
60
71
  cache_dir: config.cache_dir, project_root: config.project_root,
61
72
  load_paths: config.load_paths
62
73
  ).build_or_load
63
- boot_tests = nil
64
74
  end
65
75
 
66
76
  # Collect every (subject, mutation) up front so the pool can fan them out.
@@ -88,8 +98,7 @@ module Mutineer
88
98
  begin
89
99
  bare = WorkerPool.new(config.jobs).run(jobs) do |subject, mutation|
90
100
  run(mutation, source_file: subject.file, coverage_map: coverage_map,
91
- subject: subject, strategy: strategy,
92
- test_files: boot_tests, rails: config.rails)
101
+ subject: subject, strategy: strategy, rails: config.rails)
93
102
  end
94
103
  # The bare Results carry only status (Subjects hold live AST nodes that
95
104
  # do not marshal); reattach subject+mutation in the parent, in order.
@@ -131,24 +140,21 @@ module Mutineer
131
140
  end
132
141
 
133
142
  def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
134
- timeout: Isolation::DEFAULT_TIMEOUT, test_files: nil, rails: false)
143
+ timeout: Isolation::DEFAULT_TIMEOUT, rails: false)
135
144
  source = File.read(source_file)
136
145
  mutated = mutation.apply(source)
137
146
 
138
147
  # Validity rule: a mutant that doesn't re-parse is skipped before forking.
139
148
  return Result.skipped if Parser.parse_string(mutated).errors.any?
140
149
 
141
- # Boot mode passes its tests directly (already absolute); standalone mode
142
- # selects covering tests from the map and is :no_coverage when none cover it.
143
- if test_files
144
- abs_tests = test_files
145
- else
146
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
147
- chosen = coverage_map.tests_for(source_file, line)
148
- return Result.no_coverage if chosen.empty?
150
+ # Coverage selection (both standalone and boot mode): a mutation on a line
151
+ # no test exercises is :no_coverage (no fork); otherwise exactly the
152
+ # covering test files run in the child.
153
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
154
+ chosen = coverage_map.tests_for(source_file, line)
155
+ return Result.no_coverage if chosen.empty?
149
156
 
150
- abs_tests = chosen.map { |t| File.expand_path(t, coverage_map.project_root) }
151
- end
157
+ abs_tests = chosen.map { |t| File.expand_path(t, coverage_map.project_root) }
152
158
 
153
159
  Isolation.run(timeout: timeout) do
154
160
  # Forking inherits the parent's live DB connection; sharing one socket
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  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: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren