mutineer 0.10.0 → 0.11.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 +47 -0
- data/README.md +30 -1
- data/lib/mutineer/cli.rb +83 -50
- data/lib/mutineer/config.rb +25 -14
- data/lib/mutineer/coverage_map.rb +27 -7
- data/lib/mutineer/daemon_client.rb +17 -2
- data/lib/mutineer/daemon_server.rb +93 -9
- data/lib/mutineer/external_backend.rb +13 -1
- data/lib/mutineer/isolation.rb +18 -8
- data/lib/mutineer/mutators/statement_removal.rb +12 -10
- data/lib/mutineer/rails_worker_db.rb +137 -0
- data/lib/mutineer/reporter.rb +45 -4
- data/lib/mutineer/runner.rb +221 -53
- data/lib/mutineer/version.rb +1 -1
- metadata +2 -1
data/lib/mutineer/runner.rb
CHANGED
|
@@ -90,7 +90,7 @@ module Mutineer
|
|
|
90
90
|
load_paths: config.load_paths, framework: config.framework,
|
|
91
91
|
boot_path: File.expand_path(config.boot, config.project_root),
|
|
92
92
|
verbose: config.verbose
|
|
93
|
-
).build_via_fork(
|
|
93
|
+
).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
|
|
94
94
|
else
|
|
95
95
|
coverage_map = CoverageMap.new(
|
|
96
96
|
source_paths: config.sources, test_paths: config.tests,
|
|
@@ -112,13 +112,15 @@ module Mutineer
|
|
|
112
112
|
sweep_orphans(dirs)
|
|
113
113
|
|
|
114
114
|
strategy = config.strategy
|
|
115
|
+
# Fail-fast must be serial: a parallel stop_when fires on the first survivor
|
|
116
|
+
# by wall-clock, not input order, so the survivor set would diverge from
|
|
117
|
+
# --jobs 1 (daemon already forces serial for the same reason).
|
|
118
|
+
jobs_n = config.fail_fast ? 1 : config.jobs
|
|
115
119
|
results =
|
|
116
120
|
begin
|
|
117
121
|
framework = config.framework
|
|
118
|
-
# #21: --fail-fast stops scheduling new mutants after the first survivor;
|
|
119
|
-
# in-flight workers drain, unscheduled jobs stay nil (dropped below).
|
|
120
122
|
stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
|
|
121
|
-
bare = WorkerPool.new(
|
|
123
|
+
bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when) do |subject, mutation|
|
|
122
124
|
run(mutation, source_file: subject.file, coverage_map: coverage_map,
|
|
123
125
|
subject: subject, strategy: strategy, rails: config.rails, framework: framework)
|
|
124
126
|
end
|
|
@@ -229,69 +231,247 @@ module Mutineer
|
|
|
229
231
|
end
|
|
230
232
|
end
|
|
231
233
|
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
# runs the full `--test` set.
|
|
234
|
+
# Daemon backend: boot the app once in a persistent subprocess and fork per
|
|
235
|
+
# mutant. Tool-side we build the ready-to-`load` payload (whole-file reload by
|
|
236
|
+
# default) and ship it; the daemon needs no Prism/mutineer. Coverage is built
|
|
237
|
+
# once via a short-lived daemon so each mutant runs only its covering tests.
|
|
238
|
+
# When jobs > 1, each worker uses its own database (SQLite). Fail-fast forces
|
|
239
|
+
# serial so the survivor set matches jobs 1.
|
|
239
240
|
#
|
|
240
241
|
# @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
|
|
241
242
|
def self.execute_daemon(config, operator_classes)
|
|
242
243
|
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
243
244
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
244
|
-
|
|
245
245
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
246
|
-
|
|
246
|
+
|
|
247
|
+
# Build the coverage map once (app-side). nil when the build fails — runners
|
|
248
|
+
# fall back to the full --test set (and emit a stderr warning) rather than
|
|
249
|
+
# mis-scoring everything as no_coverage.
|
|
250
|
+
coverage_map = daemon_coverage_map(config, abs_tests)
|
|
251
|
+
|
|
252
|
+
# #26/U6: worker count = resolved --jobs, capped at the job count (no idle
|
|
253
|
+
# daemons). >1 → N concurrent daemon handles, each on its OWN worker DB (V6:
|
|
254
|
+
# N-handles, the spike-proven shape). 1 → the serial single-daemon path.
|
|
255
|
+
# --fail-fast forces serial: parallel's stop flag fires on the first survivor
|
|
256
|
+
# by WALL-CLOCK, not input index, so the verdict set would diverge from serial
|
|
257
|
+
# (a different, non-deterministic survivor set/score) — the "identical to
|
|
258
|
+
# --jobs 1" guarantee below only holds when fail-fast can't race.
|
|
259
|
+
worker_count = [config.jobs || 1, 1].max
|
|
260
|
+
worker_count = 1 if config.fail_fast
|
|
261
|
+
worker_count = [worker_count, jobs.size].min if jobs.size.positive?
|
|
262
|
+
|
|
263
|
+
results =
|
|
264
|
+
if worker_count > 1
|
|
265
|
+
run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
266
|
+
else
|
|
267
|
+
run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
[AggregateResult.new(results + ignored_results), source_map]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Build the coverage map via a short-lived daemon (boots the app once, captures
|
|
274
|
+
# per-test coverage app-side, ships the map back). Returns a query-only
|
|
275
|
+
# CoverageMap, or nil when the build fails / returns empty — callers then run the
|
|
276
|
+
# full --test set. Coverage-build IPC has no wall-clock (same limitation as
|
|
277
|
+
# in-process build_via_fork). A normal nonempty map scores like in-process;
|
|
278
|
+
# nil falls back to the full suite (more testing, not comparable).
|
|
279
|
+
#
|
|
280
|
+
# @param config [Mutineer::Config] the run config.
|
|
281
|
+
# @param abs_tests [Array<String>] absolute --test paths.
|
|
282
|
+
# @return [Mutineer::CoverageMap, nil]
|
|
283
|
+
def self.daemon_coverage_map(config, abs_tests)
|
|
284
|
+
client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests, coverage: true),
|
|
247
285
|
app_root: config.project_root).start
|
|
286
|
+
data = begin
|
|
287
|
+
client.coverage
|
|
288
|
+
ensure
|
|
289
|
+
client.quit
|
|
290
|
+
end
|
|
291
|
+
unless data && !(data["map"] || {}).empty?
|
|
292
|
+
reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
|
|
293
|
+
warn_daemon_coverage_fallback(reason)
|
|
294
|
+
return nil
|
|
295
|
+
end
|
|
248
296
|
|
|
297
|
+
CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
|
|
298
|
+
project_root: config.project_root)
|
|
299
|
+
rescue DaemonBootError => e
|
|
300
|
+
warn_daemon_coverage_fallback("#{e.class}: #{e.message}")
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Stderr note when daemon coverage is unavailable (full --test set per mutant).
|
|
305
|
+
#
|
|
306
|
+
# @api private
|
|
307
|
+
# @param reason [String] short cause (boot error message, empty map, …).
|
|
308
|
+
# @return [void]
|
|
309
|
+
def self.warn_daemon_coverage_fallback(reason = "unknown")
|
|
310
|
+
warn "[mutineer] daemon coverage map unavailable (#{reason}); running every " \
|
|
311
|
+
"mutant against the full --test set (score not comparable to an in-process run)."
|
|
312
|
+
end
|
|
313
|
+
private_class_method :warn_daemon_coverage_fallback
|
|
314
|
+
|
|
315
|
+
# Serial daemon path: one daemon (worker 0), one mutant at a time. Honors
|
|
316
|
+
# --fail-fast (#21: stop at the first survivor).
|
|
317
|
+
#
|
|
318
|
+
# @return [Array<Mutineer::Result>] results in input order.
|
|
319
|
+
def self.run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
320
|
+
client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
|
|
321
|
+
app_root: config.project_root).start
|
|
249
322
|
results = []
|
|
250
323
|
begin
|
|
251
|
-
jobs.each_with_index do |
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
# that would fail to load and read as a false `killed`.
|
|
256
|
-
r =
|
|
257
|
-
if Parser.parse_string(mutated).errors.any?
|
|
258
|
-
Result.skipped
|
|
259
|
-
else
|
|
260
|
-
verdict = client.request(
|
|
261
|
-
id: i, timeout: config.daemon_timeout || DAEMON_TIMEOUT,
|
|
262
|
-
payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
|
|
263
|
-
tests: abs_tests
|
|
264
|
-
)
|
|
265
|
-
daemon_result(verdict)
|
|
266
|
-
end
|
|
267
|
-
results << r.with(subject: subject, mutation: mutation, id: id)
|
|
268
|
-
break if config.fail_fast && r.survived? # #21: stop at the first survivor
|
|
324
|
+
jobs.each_with_index do |job, i|
|
|
325
|
+
r = daemon_job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
|
|
326
|
+
results << r
|
|
327
|
+
break if config.fail_fast && r.survived?
|
|
269
328
|
end
|
|
270
329
|
ensure
|
|
271
330
|
client.quit
|
|
272
331
|
end
|
|
332
|
+
results
|
|
333
|
+
end
|
|
273
334
|
|
|
274
|
-
|
|
335
|
+
# Parallel daemon path: N daemon handles, each pinned to its own worker slot
|
|
336
|
+
# (own DB). A shared queue of job indices feeds N tool-side threads; results
|
|
337
|
+
# are placed by input index so the verdict set matches serial. Callers must
|
|
338
|
+
# not pass fail_fast here (execute_daemon forces serial for fail-fast).
|
|
339
|
+
#
|
|
340
|
+
# @return [Array<Mutineer::Result>] completed results in input order.
|
|
341
|
+
def self.run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
342
|
+
results = Array.new(jobs.size)
|
|
343
|
+
queue = Queue.new
|
|
344
|
+
jobs.each_index { |i| queue << i }
|
|
345
|
+
|
|
346
|
+
clients = Array.new(worker_count) do
|
|
347
|
+
DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
|
|
348
|
+
app_root: config.project_root).start
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
clients.each_with_index.map do |client, worker|
|
|
352
|
+
Thread.new do
|
|
353
|
+
loop do
|
|
354
|
+
i = begin
|
|
355
|
+
queue.pop(true)
|
|
356
|
+
rescue ThreadError
|
|
357
|
+
break
|
|
358
|
+
end
|
|
359
|
+
results[i] = daemon_job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
|
|
360
|
+
end
|
|
361
|
+
ensure
|
|
362
|
+
client.quit
|
|
363
|
+
end
|
|
364
|
+
end.each(&:join)
|
|
365
|
+
|
|
366
|
+
results.compact
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Build the payload for one job, run it on the given daemon/worker, and attach
|
|
370
|
+
# the subject/mutation/id — the shared body of both daemon paths.
|
|
371
|
+
#
|
|
372
|
+
# @param job [Array(Mutineer::Subject, Mutineer::Mutation, String)] the work item.
|
|
373
|
+
# @param req_id [Integer] request id (echoed back for IPC ordering safety).
|
|
374
|
+
# @param client [Mutineer::DaemonClient] the daemon handle to run on.
|
|
375
|
+
# @param worker [Integer] the worker slot (→ worker DB) this daemon routes to.
|
|
376
|
+
# @return [Mutineer::Result] the decorated result.
|
|
377
|
+
def self.daemon_job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map)
|
|
378
|
+
subject, mutation, id = job
|
|
379
|
+
source = source_map[subject.file]
|
|
380
|
+
mutated = mutation.apply(source)
|
|
381
|
+
# KTD-8 (carried): skip an invalid mutant tool-side — never ship a payload that
|
|
382
|
+
# would fail to load and read as a false `killed`.
|
|
383
|
+
# #26/U7: narrow to covering tests (shared with the in-process path via
|
|
384
|
+
# coverage_selection, so scores match). :verdict = no_coverage/uncapturable, no
|
|
385
|
+
# fork. No map (build failed) → run the full --test set (fallback, not narrowed).
|
|
386
|
+
sel = coverage_map && coverage_selection(subject.file, mutation, subject, source, coverage_map)
|
|
387
|
+
r =
|
|
388
|
+
if Parser.parse_string(mutated).errors.any?
|
|
389
|
+
Result.skipped
|
|
390
|
+
elsif sel && sel[0] == :verdict
|
|
391
|
+
sel[1]
|
|
392
|
+
else
|
|
393
|
+
verdict = client.request(
|
|
394
|
+
id: req_id, worker: worker, timeout: config.daemon_timeout || DAEMON_TIMEOUT,
|
|
395
|
+
payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
|
|
396
|
+
tests: sel ? sel[1] : abs_tests
|
|
397
|
+
)
|
|
398
|
+
daemon_result(verdict)
|
|
399
|
+
end
|
|
400
|
+
r.with(subject: subject, mutation: mutation, id: id)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Coverage-based test selection, shared by the in-process ({run}) and daemon
|
|
404
|
+
# paths so both narrow identically (score parity, U7/V5). Returns
|
|
405
|
+
# `[:run, abs_test_paths]` when some test covers the mutant's line, or
|
|
406
|
+
# `[:verdict, Result]` (no_coverage / uncapturable) when none do.
|
|
407
|
+
#
|
|
408
|
+
# #9/#25: an empty selection is `:uncapturable` (not `:no_coverage`) when the
|
|
409
|
+
# mutant's enclosing method body got coverage from no *successful* capture but a
|
|
410
|
+
# sibling test failed to capture — the coverage was lost, not absent. Both are
|
|
411
|
+
# excluded from the score denominator, so this distinction is reporting-only and
|
|
412
|
+
# never changes the daemon-vs-in-process score.
|
|
413
|
+
#
|
|
414
|
+
# @param source_file [String] the mutated source file path.
|
|
415
|
+
# @param mutation [Mutineer::Mutation] the mutation (for its line offset).
|
|
416
|
+
# @param subject [Mutineer::Subject, nil] the subject (for its method body range).
|
|
417
|
+
# @param source [String] the original source text.
|
|
418
|
+
# @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
|
|
419
|
+
# @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
|
|
420
|
+
def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
421
|
+
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
422
|
+
chosen = coverage_map.tests_for(source_file, line)
|
|
423
|
+
if chosen.empty?
|
|
424
|
+
# Method BODY range, not the whole def: the def/end lines are "covered" at
|
|
425
|
+
# class-load even when the body never runs (body_loc is the statements' span).
|
|
426
|
+
loc = subject&.body_loc
|
|
427
|
+
range = loc ? (loc.start_line..loc.end_line) : (line..line)
|
|
428
|
+
return [:verdict, coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage]
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
[:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
|
|
275
432
|
end
|
|
276
433
|
|
|
277
|
-
# Default per-mutant timeout on the daemon path.
|
|
278
|
-
#
|
|
434
|
+
# Default per-mutant timeout on the daemon path (seconds). Coverage narrowing
|
|
435
|
+
# usually keeps each job short; this still covers a slow suite or full-suite
|
|
436
|
+
# fallback when the coverage map is unavailable.
|
|
279
437
|
DAEMON_TIMEOUT = 60
|
|
280
438
|
|
|
281
439
|
# The boot config the daemon needs to boot the app once: where to boot, the test
|
|
282
440
|
# load roots (so `require "test_helper"` resolves in every fork), framework, and
|
|
283
441
|
# whether this is Rails.
|
|
284
|
-
def self.daemon_boot_config(config, abs_tests)
|
|
442
|
+
def self.daemon_boot_config(config, abs_tests, coverage: false)
|
|
285
443
|
{
|
|
286
444
|
project_root: config.project_root,
|
|
287
445
|
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
288
446
|
load_paths: test_load_roots(abs_tests),
|
|
289
447
|
source_dirs: source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
290
448
|
framework: config.framework,
|
|
291
|
-
rails: config.rails
|
|
449
|
+
rails: config.rails,
|
|
450
|
+
# #26/U5: schema for per-worker DB isolation. Sent when present; the daemon
|
|
451
|
+
# skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
|
|
452
|
+
schema: daemon_schema_path(config),
|
|
453
|
+
# #26/U7: coverage narrowing. Only the short-lived map-building daemon starts
|
|
454
|
+
# Coverage (before boot); worker daemons boot with it OFF (no wasted
|
|
455
|
+
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
456
|
+
# map-build inputs.
|
|
457
|
+
coverage: coverage,
|
|
458
|
+
sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
|
|
459
|
+
tests: abs_tests
|
|
292
460
|
}
|
|
293
461
|
end
|
|
294
462
|
|
|
463
|
+
# Absolute path to the app's `db/schema.rb` if it exists, else nil. Used by the
|
|
464
|
+
# daemon to schema-load each fork's isolated worker database (#26/U5). Only
|
|
465
|
+
# `schema.rb` is supported this pass; `structure.sql` apps get nil and fall back to
|
|
466
|
+
# whatever the worker DB already holds (Postgres provisioning is U10).
|
|
467
|
+
#
|
|
468
|
+
# @param config [Mutineer::Config] the run config.
|
|
469
|
+
# @return [String, nil] absolute schema path or nil.
|
|
470
|
+
def self.daemon_schema_path(config)
|
|
471
|
+
path = File.expand_path("db/schema.rb", config.project_root)
|
|
472
|
+
File.exist?(path) ? path : nil
|
|
473
|
+
end
|
|
474
|
+
|
|
295
475
|
# Map a daemon verdict string to a Result. The daemon reports the four run-time
|
|
296
476
|
# states it can decide (KTD-5); pre-fork states (skipped/no_coverage/…) are
|
|
297
477
|
# resolved tool-side before a request is ever sent.
|
|
@@ -426,24 +606,12 @@ module Mutineer
|
|
|
426
606
|
|
|
427
607
|
# Coverage selection (both standalone and boot mode): a mutation on a line
|
|
428
608
|
# no test exercises is :no_coverage (no fork); otherwise exactly the
|
|
429
|
-
# covering test files run in the child.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
# errored during capture (coverage lost) — the latter is :uncapturable.
|
|
434
|
-
# #25: taint per-METHOD (the mutant's enclosing def range), not whole-file,
|
|
435
|
-
# so a covered method's uncovered line stays :no_coverage while a method
|
|
436
|
-
# reachable only by a failed capture is :uncapturable.
|
|
437
|
-
if chosen.empty?
|
|
438
|
-
# Use the method BODY range, not the whole def: the `def`/`end` lines are
|
|
439
|
-
# "covered" at class-load even when the body never runs, which would mask
|
|
440
|
-
# an uncovered method. body_loc is the body statements' span.
|
|
441
|
-
loc = subject&.body_loc
|
|
442
|
-
range = loc ? (loc.start_line..loc.end_line) : (line..line)
|
|
443
|
-
return coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage
|
|
444
|
-
end
|
|
609
|
+
# covering test files run in the child. Shared with the daemon path so both
|
|
610
|
+
# narrow identically (score parity, U7/V5).
|
|
611
|
+
kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
612
|
+
return payload if kind == :verdict
|
|
445
613
|
|
|
446
|
-
abs_tests =
|
|
614
|
+
abs_tests = payload
|
|
447
615
|
|
|
448
616
|
Isolation.run(timeout: timeout) do
|
|
449
617
|
# Forking inherits the parent's live DB connection; sharing one socket
|
data/lib/mutineer/version.rb
CHANGED
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: 0.11.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- lib/mutineer/pairing.rb
|
|
96
96
|
- lib/mutineer/parser.rb
|
|
97
97
|
- lib/mutineer/project.rb
|
|
98
|
+
- lib/mutineer/rails_worker_db.rb
|
|
98
99
|
- lib/mutineer/reporter.rb
|
|
99
100
|
- lib/mutineer/result.rb
|
|
100
101
|
- lib/mutineer/runner.rb
|