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.
@@ -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(rails: config.rails)
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(config.jobs).run(jobs, stop_when: stop_when) do |subject, mutation|
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
- # #26/#27 Phase 2a daemon backend orchestration (serial). Boots the app ONCE in
233
- # a persistent subprocess and forks per mutant, restoring the one-boot speed the
234
- # Phase 1 subprocess path gives up. Tool-side we build the ready-to-`load` payload
235
- # (KTD-2/KTD-3: whole-file reload by default the spike-proven path) and ship it;
236
- # the daemon needs no Prism/mutineer. Serial in 2a (worker-DB isolation +
237
- # parallelism is Phase 2b). No coverage narrowing yet (Phase 2c), so every mutant
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
- client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
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 |(subject, mutation, id), i|
252
- source = source_map[subject.file]
253
- mutated = mutation.apply(source)
254
- # KTD-8 (carried): skip an invalid mutant tool-side — never ship a payload
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
- [AggregateResult.new(results + ignored_results), source_map]
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. Generous because 2a runs the full
278
- # `--test` set per mutant (no coverage narrowing until Phase 2c).
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
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
431
- chosen = coverage_map.tests_for(source_file, line)
432
- # #9/#25: distinguish a genuine coverage gap from a line whose would-be test
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 = chosen.map { |t| File.expand_path(t, coverage_map.project_root) }
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.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: 0.10.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