mutineer 0.11.4 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -0
- data/README.md +20 -4
- data/lib/mutineer/baseline.rb +48 -10
- data/lib/mutineer/changed_lines.rb +11 -3
- data/lib/mutineer/cli.rb +18 -3
- data/lib/mutineer/config.rb +5 -0
- data/lib/mutineer/coverage_map.rb +404 -30
- data/lib/mutineer/daemon_backend.rb +18 -2
- data/lib/mutineer/daemon_client.rb +7 -4
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/file_swap.rb +136 -40
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/reporter.rb +22 -6
- data/lib/mutineer/runner.rb +59 -32
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +7 -1
- data/lib/mutineer.rb +1 -0
- metadata +3 -1
data/lib/mutineer/file_swap.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
3
6
|
module Mutineer
|
|
4
|
-
# Raised when
|
|
5
|
-
#
|
|
6
|
-
# and unlocked). Aborting beats silently leaving the tree mutated.
|
|
7
|
+
# Raised when another process already holds exclusive ownership of a source
|
|
8
|
+
# file. Aborting beats silently restoring (or capturing) the other run's mutant.
|
|
7
9
|
class ConcurrentRunError < StandardError
|
|
8
|
-
def initialize(
|
|
9
|
-
super("
|
|
10
|
-
"in this directory? Aborting to avoid corrupting the source file.")
|
|
10
|
+
def initialize(path)
|
|
11
|
+
super("another mutineer run owns #{path} — aborting to avoid corrupting the source file.")
|
|
11
12
|
end
|
|
12
13
|
end
|
|
13
14
|
|
|
@@ -19,51 +20,92 @@ module Mutineer
|
|
|
19
20
|
#
|
|
20
21
|
# Defense in depth, mirroring the tempfile-orphan discipline
|
|
21
22
|
# (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
|
|
23
|
+
# - exclusive OS ownership (flock) is acquired before swap or recovery;
|
|
22
24
|
# - the original bytes are held in memory AND written to a sibling backup;
|
|
23
25
|
# - `ensure` restores from memory around every mutant;
|
|
24
26
|
# - the backup survives a SIGKILL (which skips `ensure`), so `restore_orphans`
|
|
25
|
-
# can self-heal a left-mutated tree on the next run's startup
|
|
27
|
+
# can self-heal a left-mutated tree on the next run's startup once the
|
|
28
|
+
# kernel has released the dead owner's lock.
|
|
26
29
|
# Only one mutant is in flight per file at a time (the external path is serial),
|
|
27
30
|
# so backups never collide.
|
|
28
31
|
module FileSwap
|
|
29
32
|
# Suffix for the on-disk backup; fixed so `restore_orphans` finds it.
|
|
30
33
|
BACKUP_SUFFIX = ".mutineer-backup"
|
|
31
34
|
|
|
35
|
+
# Subdirectory beside the source that holds flock files (stable across cwd
|
|
36
|
+
# and `cache_dir`, so concurrent runs on one inode share one lock).
|
|
37
|
+
LOCK_DIR_NAME = "file-swap-locks"
|
|
38
|
+
|
|
39
|
+
# Canonical source path => open lock File held by this process.
|
|
40
|
+
# @api private
|
|
41
|
+
OWNED = {}
|
|
42
|
+
|
|
43
|
+
# Real path when the file exists, otherwise `File.expand_path`. Symlink
|
|
44
|
+
# aliases of one inode share this identity for locks, backups, and ownership.
|
|
45
|
+
#
|
|
46
|
+
# @param path [String] source path, relative or absolute.
|
|
47
|
+
# @return [String] canonical absolute path.
|
|
48
|
+
def self.canonical_path(path)
|
|
49
|
+
expanded = File.expand_path(path)
|
|
50
|
+
File.exist?(expanded) ? File.realpath(expanded) : expanded
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Holds exclusive OS ownership of each source path for the duration of the
|
|
54
|
+
# block. Re-entrant for paths this process already owns. Raises
|
|
55
|
+
# {ConcurrentRunError} when another process holds a path (non-blocking).
|
|
56
|
+
#
|
|
57
|
+
# @param paths [Array<String>] source file paths to own.
|
|
58
|
+
# @yield the block to run while ownership is held.
|
|
59
|
+
# @return [Object] the block's return value.
|
|
60
|
+
def self.owning(paths)
|
|
61
|
+
acquired = []
|
|
62
|
+
Array(paths).map { |p| canonical_path(p) }.uniq.sort.each do |path|
|
|
63
|
+
next if OWNED.key?(path)
|
|
64
|
+
|
|
65
|
+
acquire!(path)
|
|
66
|
+
acquired << path
|
|
67
|
+
end
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
acquired.reverse_each { |path| release!(path) }
|
|
71
|
+
end
|
|
72
|
+
|
|
32
73
|
# Writes `mutated` to `source_file`, yields, then restores the original bytes
|
|
33
74
|
# on every exit path (normal return, exception, or `ensure`). Byte-exact:
|
|
34
75
|
# binary read/write preserves encoding, newlines, and trailing bytes.
|
|
76
|
+
# Acquires exclusive ownership first; a leftover backup with no live owner is
|
|
77
|
+
# treated as the original (a prior hard-killed run), not a concurrent run.
|
|
35
78
|
#
|
|
36
79
|
# @param source_file [String] path to the real source file.
|
|
37
80
|
# @param mutated [String] mutated source text to write for the duration.
|
|
38
81
|
# @yield the block to run while the mutant is on disk.
|
|
39
82
|
# @return [Object] the block's return value.
|
|
40
83
|
def self.with(source_file, mutated)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
File.binwrite(source_file, original)
|
|
59
|
-
File.unlink(backup) if File.exist?(backup)
|
|
84
|
+
path = canonical_path(source_file)
|
|
85
|
+
created = false
|
|
86
|
+
original = nil
|
|
87
|
+
backup = path + BACKUP_SUFFIX
|
|
88
|
+
owning([path]) do
|
|
89
|
+
begin
|
|
90
|
+
original = File.exist?(backup) ? File.binread(backup) : File.binread(path)
|
|
91
|
+
File.binwrite(backup, original)
|
|
92
|
+
created = true
|
|
93
|
+
File.binwrite(path, mutated)
|
|
94
|
+
yield
|
|
95
|
+
ensure
|
|
96
|
+
if created
|
|
97
|
+
File.binwrite(path, original)
|
|
98
|
+
File.unlink(backup) if File.exist?(backup)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
60
101
|
end
|
|
61
102
|
end
|
|
62
103
|
|
|
63
104
|
# Startup/after-run self-heal: restore any source file left mutated by a prior
|
|
64
105
|
# interrupted run (a leftover `*.mutineer-backup`), then remove the backup.
|
|
65
|
-
#
|
|
66
|
-
#
|
|
106
|
+
# Skips a backup whose source is owned by a live process. Prints one line to
|
|
107
|
+
# stderr when it actually heals something, so a developer knows their working
|
|
108
|
+
# tree was auto-restored (a file they did not touch).
|
|
67
109
|
#
|
|
68
110
|
# @param dirs [Array<String>] directories to sweep for orphaned backups.
|
|
69
111
|
# @return [void]
|
|
@@ -72,19 +114,10 @@ module Mutineer
|
|
|
72
114
|
dirs.uniq.each do |dir|
|
|
73
115
|
Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup|
|
|
74
116
|
source_file = backup.delete_suffix(BACKUP_SUFFIX)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# restore. Leave it untouched (never create a file from it).
|
|
117
|
+
begin
|
|
118
|
+
owning([source_file]) { healed += restore_one(backup, source_file) }
|
|
119
|
+
rescue ConcurrentRunError
|
|
79
120
|
next
|
|
80
|
-
elsif File.binread(source_file) == backup_bytes
|
|
81
|
-
# Redundant backup (e.g. a crash between restore and unlink): nothing
|
|
82
|
-
# to heal, just clear the orphan so the next run does not see a false race.
|
|
83
|
-
File.unlink(backup)
|
|
84
|
-
else
|
|
85
|
-
File.binwrite(source_file, backup_bytes)
|
|
86
|
-
File.unlink(backup)
|
|
87
|
-
healed += 1
|
|
88
121
|
end
|
|
89
122
|
end
|
|
90
123
|
end
|
|
@@ -92,5 +125,68 @@ module Mutineer
|
|
|
92
125
|
|
|
93
126
|
warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run."
|
|
94
127
|
end
|
|
128
|
+
|
|
129
|
+
# Exclusive non-blocking flock for canonical `path`.
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
# @param path [String] canonical source path.
|
|
133
|
+
# @return [void]
|
|
134
|
+
# @raise [Mutineer::ConcurrentRunError] when the lock is held elsewhere.
|
|
135
|
+
def self.acquire!(path)
|
|
136
|
+
file = File.open(lock_file(path), File::RDWR | File::CREAT, 0o644)
|
|
137
|
+
unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
138
|
+
file.close
|
|
139
|
+
raise ConcurrentRunError, path
|
|
140
|
+
end
|
|
141
|
+
OWNED[path] = file
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Flock path for a canonical source: `<dir>/.mutineer/file-swap-locks/<sha>`.
|
|
145
|
+
# Derived only from the source path so cwd and `cache_dir` cannot split a lock.
|
|
146
|
+
#
|
|
147
|
+
# @api private
|
|
148
|
+
# @param path [String] canonical source path.
|
|
149
|
+
# @return [String] lock file path.
|
|
150
|
+
def self.lock_file(path)
|
|
151
|
+
dir = File.join(File.dirname(path), ".mutineer", LOCK_DIR_NAME)
|
|
152
|
+
FileUtils.mkdir_p(dir)
|
|
153
|
+
File.join(dir, Digest::SHA256.hexdigest(path))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Releases a lock acquired by {acquire!}.
|
|
157
|
+
#
|
|
158
|
+
# @api private
|
|
159
|
+
# @param path [String] expanded source path.
|
|
160
|
+
# @return [void]
|
|
161
|
+
def self.release!(path)
|
|
162
|
+
file = OWNED.delete(path)
|
|
163
|
+
return unless file
|
|
164
|
+
|
|
165
|
+
file.flock(File::LOCK_UN)
|
|
166
|
+
file.close
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Restores one backup if the sibling source exists. Returns 1 when bytes
|
|
172
|
+
# were written back, 0 when the backup was redundant or had no sibling.
|
|
173
|
+
#
|
|
174
|
+
# @api private
|
|
175
|
+
# @param backup [String] path to the `*.mutineer-backup` file.
|
|
176
|
+
# @param source_file [String] corresponding source path.
|
|
177
|
+
# @return [Integer] 1 if healed, otherwise 0.
|
|
178
|
+
def self.restore_one(backup, source_file)
|
|
179
|
+
return 0 unless File.exist?(source_file)
|
|
180
|
+
|
|
181
|
+
backup_bytes = File.binread(backup)
|
|
182
|
+
if File.binread(source_file) == backup_bytes
|
|
183
|
+
File.unlink(backup)
|
|
184
|
+
0
|
|
185
|
+
else
|
|
186
|
+
File.binwrite(source_file, backup_bytes)
|
|
187
|
+
File.unlink(backup)
|
|
188
|
+
1
|
|
189
|
+
end
|
|
190
|
+
end
|
|
95
191
|
end
|
|
96
192
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Coarse run progress on stderr: one line per 10% step of completed mutants
|
|
5
|
+
# (which means every completion on a run smaller than ten). Shared by every
|
|
6
|
+
# backend so a long run is never silent between config resolution and the
|
|
7
|
+
# report. Thread-safe so the daemon's parallel workers can share one instance.
|
|
8
|
+
# Writes only to stderr, keeping `--format json` stdout and `--output` files
|
|
9
|
+
# byte-exact per the Reporter's stream contract.
|
|
10
|
+
class Progress
|
|
11
|
+
# Builds a progress counter for a run of `total` mutants.
|
|
12
|
+
#
|
|
13
|
+
# @param total [Integer] number of mutants that will run.
|
|
14
|
+
# @param io [IO] destination stream (defaults to stderr; injectable for tests).
|
|
15
|
+
def initialize(total, io: $stderr)
|
|
16
|
+
@total = total
|
|
17
|
+
@done = 0
|
|
18
|
+
@last_step = 0
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@io = io
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Records one completed mutant and prints when a 10% boundary is crossed.
|
|
24
|
+
# A zero-total counter never prints (guards the division, and an empty run
|
|
25
|
+
# has nothing to report).
|
|
26
|
+
#
|
|
27
|
+
# @return [void]
|
|
28
|
+
def tick
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
return if @total.zero?
|
|
31
|
+
|
|
32
|
+
@done += 1
|
|
33
|
+
step = (@done * 10) / @total
|
|
34
|
+
return if step <= @last_step
|
|
35
|
+
|
|
36
|
+
@last_step = step
|
|
37
|
+
@io.puts "[mutineer] #{@done}/#{@total} mutants (#{(@done * 100) / @total}%)"
|
|
38
|
+
end
|
|
39
|
+
rescue IOError, Errno::EPIPE
|
|
40
|
+
# Progress is best-effort: a closed stderr (piped consumer went away) must
|
|
41
|
+
# never kill the run — in the daemon's worker threads a raise here would
|
|
42
|
+
# propagate through Thread#join and abort scoring.
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -32,11 +32,14 @@ module Mutineer
|
|
|
32
32
|
|
|
33
33
|
# Single entry point. Branches on `format` ("human" | "json" | "html") and
|
|
34
34
|
# routes the rendered report to `output` (a file, with a stderr confirmation)
|
|
35
|
-
# or to `out`. Diagnostics always go to `err`.
|
|
36
|
-
|
|
35
|
+
# or to `out`. Diagnostics always go to `err`. `scoped` marks a diff-scoped
|
|
36
|
+
# (`--since`) run; the JSON report records it so a consumer (or a later
|
|
37
|
+
# `--baseline` load) knows the score covers only the changed-line mutants.
|
|
38
|
+
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
|
|
39
|
+
baseline: nil, scoped: false)
|
|
37
40
|
rendered =
|
|
38
41
|
if format == "json"
|
|
39
|
-
json_report(baseline)
|
|
42
|
+
json_report(baseline, scoped: scoped)
|
|
40
43
|
elsif format == "html"
|
|
41
44
|
html_report
|
|
42
45
|
else
|
|
@@ -126,8 +129,10 @@ module Mutineer
|
|
|
126
129
|
#
|
|
127
130
|
# @api private
|
|
128
131
|
# @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
|
|
132
|
+
# @param scoped [Boolean] the run was diff-scoped (`--since`), so its score
|
|
133
|
+
# covers only the changed-line mutants (additive `summary.scoped` key).
|
|
129
134
|
# @return [String] JSON text.
|
|
130
|
-
def json_report(baseline = nil)
|
|
135
|
+
def json_report(baseline = nil, scoped: false)
|
|
131
136
|
killed = @agg.killed_count
|
|
132
137
|
survived = @agg.survived_count
|
|
133
138
|
# null (not 0.0) on an empty denominator, matching the nil-vs-0.0
|
|
@@ -136,7 +141,7 @@ module Mutineer
|
|
|
136
141
|
score = @agg.mutation_score
|
|
137
142
|
|
|
138
143
|
doc = {
|
|
139
|
-
schema_version: "1.
|
|
144
|
+
schema_version: "1.3",
|
|
140
145
|
summary: {
|
|
141
146
|
total: @agg.total, killed: killed, survived: survived,
|
|
142
147
|
no_coverage: @agg.no_coverage_count,
|
|
@@ -146,7 +151,11 @@ module Mutineer
|
|
|
146
151
|
ignored: @agg.ignored_count,
|
|
147
152
|
# The gate is computed from these two, so a consumer never re-derives them.
|
|
148
153
|
attempted: attempted_count, no_verdict: no_verdict_count,
|
|
149
|
-
score: score
|
|
154
|
+
score: score,
|
|
155
|
+
# Additive: true when the run was diff-scoped (--since). The score then
|
|
156
|
+
# covers only the changed-line mutants, so it is not comparable to a
|
|
157
|
+
# full-run score; Baseline#diff reads this to skip the score-drop gate.
|
|
158
|
+
scoped: scoped
|
|
150
159
|
},
|
|
151
160
|
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
152
161
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
@@ -306,6 +315,10 @@ module Mutineer
|
|
|
306
315
|
score_before: delta.score_before,
|
|
307
316
|
score_after: delta.score_after,
|
|
308
317
|
score_dropped: delta.score_drop,
|
|
318
|
+
# Additive: false when the score-drop check was skipped (a nil score or
|
|
319
|
+
# a diff-scoped side), so a consumer knows not to render the two scores
|
|
320
|
+
# as a comparison.
|
|
321
|
+
score_comparable: delta.score_comparable,
|
|
309
322
|
new_survivors: delta.new_survivors.map { |r| ignored_json(r) }
|
|
310
323
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
311
324
|
fixed_survivors: delta.fixed_survivors.map do |h|
|
|
@@ -577,6 +590,9 @@ module Mutineer
|
|
|
577
590
|
out.puts " + #{r.subject.qualified_name} (#{file}:#{line}) #{r.mutation.operator}"
|
|
578
591
|
end
|
|
579
592
|
out.puts "score dropped #{delta.score_before}% -> #{delta.score_after}%" if delta.score_drop
|
|
593
|
+
# An OK verdict must not imply a check that never ran: say when the score
|
|
594
|
+
# comparison was skipped (a diff-scoped side or a nil score).
|
|
595
|
+
out.puts "score-drop check skipped (scores not comparable)" unless delta.score_comparable
|
|
580
596
|
out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
|
|
581
597
|
end
|
|
582
598
|
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "coverage_map"
|
|
|
10
10
|
require_relative "changed_lines"
|
|
11
11
|
require_relative "mutator_registry"
|
|
12
12
|
require_relative "worker_pool"
|
|
13
|
+
require_relative "progress"
|
|
13
14
|
require_relative "mutant_id"
|
|
14
15
|
require_relative "file_swap"
|
|
15
16
|
require_relative "external_backend"
|
|
@@ -98,6 +99,7 @@ module Mutineer
|
|
|
98
99
|
load_paths: config.load_paths, framework: config.framework
|
|
99
100
|
).build_or_load
|
|
100
101
|
end
|
|
102
|
+
abort_if_unclean!(coverage_map)
|
|
101
103
|
|
|
102
104
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
103
105
|
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
@@ -121,7 +123,9 @@ module Mutineer
|
|
|
121
123
|
begin
|
|
122
124
|
framework = config.framework
|
|
123
125
|
stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
|
|
124
|
-
|
|
126
|
+
progress = Progress.new(jobs.size)
|
|
127
|
+
bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when,
|
|
128
|
+
on_result: ->(_r) { progress.tick }) do |subject, mutation|
|
|
125
129
|
run(mutation, source_file: subject.file, coverage_map: coverage_map,
|
|
126
130
|
subject: subject, strategy: strategy, rails: config.rails, framework: framework)
|
|
127
131
|
end
|
|
@@ -181,42 +185,50 @@ module Mutineer
|
|
|
181
185
|
# @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
|
|
182
186
|
def self.execute_external(config, operator_classes)
|
|
183
187
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
184
|
-
|
|
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)
|
|
185
200
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# still-mutated file would yield garbage offsets against the later-healed
|
|
189
|
-
# source. Heal first, then discover jobs from the clean tree.
|
|
190
|
-
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
|
|
191
203
|
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
194
215
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
results = []
|
|
208
|
-
begin
|
|
209
|
-
jobs.each do |subject, mutation, id|
|
|
210
|
-
r = run_external(subject, mutation, config.test_command, abs_tests,
|
|
211
|
-
timeout: timeout, verbose: config.verbose)
|
|
212
|
-
results << r.with(subject: subject, mutation: mutation, id: id)
|
|
213
|
-
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)
|
|
214
228
|
end
|
|
215
|
-
ensure
|
|
216
|
-
FileSwap.restore_orphans(dirs)
|
|
217
|
-
end
|
|
218
229
|
|
|
219
|
-
|
|
230
|
+
[AggregateResult.new(results + ignored_results), source_map]
|
|
231
|
+
end
|
|
220
232
|
end
|
|
221
233
|
|
|
222
234
|
# Runs one mutant through the external backend: apply the whole-file mutation
|
|
@@ -236,6 +248,21 @@ module Mutineer
|
|
|
236
248
|
end
|
|
237
249
|
end
|
|
238
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
|
+
|
|
239
266
|
# Coverage-based test selection, shared by the in-process ({run}) and daemon
|
|
240
267
|
# paths so both narrow identically (score parity). Returns
|
|
241
268
|
# `[:run, abs_test_paths]` when some test covers the mutant's line, or
|
data/lib/mutineer/version.rb
CHANGED
data/lib/mutineer/worker_pool.rb
CHANGED
|
@@ -28,10 +28,15 @@ module Mutineer
|
|
|
28
28
|
# @param stop_when [Proc, nil] called with each collected Result; when it
|
|
29
29
|
# returns truthy, no further items are scheduled and the run drains and
|
|
30
30
|
# returns early (--fail-fast). Unscheduled slots stay nil.
|
|
31
|
+
# @param on_result [Proc, nil] called in the parent with each collected
|
|
32
|
+
# Result as it is reaped, in finish order (progress reporting); its
|
|
33
|
+
# return value is ignored. Must be fast and non-blocking: it runs on the
|
|
34
|
+
# pool's single reap thread, so a slow callback stalls draining the other
|
|
35
|
+
# in-flight children's pipes (the #4 deadlock discipline).
|
|
31
36
|
# @yieldparam item [Array] one work item.
|
|
32
37
|
# @return [Array<Mutineer::Result>] results in input order (nil for any item
|
|
33
38
|
# left unscheduled by an early stop).
|
|
34
|
-
def run(items, stop_when: nil)
|
|
39
|
+
def run(items, stop_when: nil, on_result: nil)
|
|
35
40
|
results = Array.new(items.size)
|
|
36
41
|
queue = (0...items.size).to_a
|
|
37
42
|
running = {} # pid => [index, read_io, buffer]
|
|
@@ -40,6 +45,7 @@ module Mutineer
|
|
|
40
45
|
until queue.empty? && running.empty?
|
|
41
46
|
fill(items, queue, running) { |*args| yield(*args) } unless stopping
|
|
42
47
|
result = reap(results, running)
|
|
48
|
+
on_result&.call(result) if result
|
|
43
49
|
if !stopping && stop_when && result && stop_when.call(result)
|
|
44
50
|
stopping = true
|
|
45
51
|
queue.clear # schedule no more; let in-flight workers drain
|
data/lib/mutineer.rb
CHANGED
|
@@ -27,6 +27,7 @@ require_relative "mutineer/mutators/regex_literal"
|
|
|
27
27
|
require_relative "mutineer/mutators/collection_method"
|
|
28
28
|
require_relative "mutineer/mutator_registry"
|
|
29
29
|
require_relative "mutineer/worker_pool"
|
|
30
|
+
require_relative "mutineer/progress"
|
|
30
31
|
require_relative "mutineer/runner"
|
|
31
32
|
require_relative "mutineer/reporter"
|
|
32
33
|
require_relative "mutineer/baseline"
|
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.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- lib/mutineer/mutators/string_literal.rb
|
|
96
96
|
- lib/mutineer/pairing.rb
|
|
97
97
|
- lib/mutineer/parser.rb
|
|
98
|
+
- lib/mutineer/progress.rb
|
|
98
99
|
- lib/mutineer/project.rb
|
|
99
100
|
- lib/mutineer/rails_worker_db.rb
|
|
100
101
|
- lib/mutineer/reporter.rb
|
|
@@ -113,6 +114,7 @@ metadata:
|
|
|
113
114
|
source_code_uri: https://github.com/davidteren/mutineer
|
|
114
115
|
changelog_uri: https://github.com/davidteren/mutineer/blob/main/CHANGELOG.md
|
|
115
116
|
bug_tracker_uri: https://github.com/davidteren/mutineer/issues
|
|
117
|
+
documentation_uri: https://davidteren.github.io/mutineer/
|
|
116
118
|
rubygems_mfa_required: 'true'
|
|
117
119
|
rdoc_options: []
|
|
118
120
|
require_paths:
|