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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +3 -3
- data/lib/mutineer/coverage_map.rb +99 -14
- data/lib/mutineer/runner.rb +25 -19
- data/lib/mutineer/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51400ef0019b1a18f5c4b548e7789d072cbd744bd7918aa402de83052b37d5b6
|
|
4
|
+
data.tar.gz: 31d84ebaf51dbed8a5fc9273aece37f2ea288af9e41efd8006c939439c318125
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
#
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
data/lib/mutineer/version.rb
CHANGED