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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92b74299812649a42303c4387412bd3bd67014e7e91c656df36489836773ae08
4
- data.tar.gz: 20c25fed6e5a490deb8d5f9068cd47187efe16fdfa0625a18e9e1fe7296b17f6
3
+ metadata.gz: 42d278a96730cdf9616492a720c701ba9119195299f64fa74b339b3737e4e545
4
+ data.tar.gz: ef2148e5752ea9790403ca256e4bc6672b5e8c2656289d74b9cd3ffcd067f475
5
5
  SHA512:
6
- metadata.gz: 0d838506311eff5c879ee9b23701a995c112a84ce4d7e0cfa9d1b43788ff39eb39cfafcbc4111db1547feb5f5874c0f5e07489f2c7597235c3cd37ef360d407f
7
- data.tar.gz: 15ec124714bb63d42a2ac95e5ea8582f4329f7577e8fabfe7e544fe062e5667b60bcb9b3d07b167e731c3b7980a7c71ed18f368b7c8fe4d32d20f28021e0cdb0
6
+ metadata.gz: 82879be78e820ef184dca76bf52ad883298f3c0e8becdb6344061b32321f44945464025c6f7d8064e9b649486b6b5f258b60b43baf0ec6f0d38356b314af5793
7
+ data.tar.gz: 67399daaa12b9b312a4ad0fcad144ae763783d2f5994e474b1303ad7ed0ddd5c5d42fba5b39c750115a0614356b4e4a2593238b91e39eee38edd96981c4afa5a
data/CHANGELOG.md CHANGED
@@ -6,6 +6,50 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.1] - 2026-07-23
10
+
11
+ ### Fixed
12
+ - **`--daemon` score disclosure** — stop claiming “no coverage narrowing / lower
13
+ bound” on every run (narrowing already ships). Warn only when the coverage map
14
+ is unavailable and the run falls back to the full `--test` set (#48).
15
+ - **`--threshold` CI fidelity** — exit 1 when a positive threshold is set but the
16
+ run produced only errors/timeouts/uncapturable (nil score with a broken harness)
17
+ instead of silently green; pure no_coverage / all-ignored still skips the gate
18
+ (#49). Non-numeric `--threshold` / YAML `threshold` is a usage error (exit 2),
19
+ not a silent gate-off (#51).
20
+ - **`--daemon` contracts** — reject `--framework rspec` (exit 2); force
21
+ `--strategy reload` with a clear warning when redefine was requested (daemon
22
+ whole-file only) (#50). **In-process `--rails` always serial** unless
23
+ `--daemon` (only daemon has per-worker DB isolation) (#55).
24
+ - **Isolation timeout** — kill the child process group and honor a clean exit
25
+ that races the deadline (not always timeout) (#52). **External backend** —
26
+ signal death is `error`, not `killed` (#53).
27
+ - **`--fail-fast` is serial** on the in-process path (matches daemon) so the
28
+ survivor set is deterministic under any `--jobs` (#54).
29
+ - **Dry-run uses `collect_jobs`** so candidates match a real run (#59).
30
+ - **Daemon schema load once per worker** (not every mutant fork) (#56).
31
+ - **Nested `statement_removal`** visits inner statement lists (#62).
32
+ - Dead seams: remove unused `RailsWorkerDb.provision`, simplify parallel daemon
33
+ fail-fast branch, drop unused `validate_daemon!` arg (#60).
34
+ - Archive historical implementation spec under `docs/archive/` (#61).
35
+
36
+ ## [0.11.0] - 2026-07-02
37
+
38
+ ### Added
39
+ - **`--daemon` backend — fast, parallel-safe Rails mutation testing** (#26/#27
40
+ Phase 2). Boots the app **once** in a persistent daemon and forks per mutant
41
+ (restoring shared-boot speed), and gives **each parallel worker its own
42
+ database** so `--jobs N` is safe under Rails for the first time — parallel
43
+ verdicts are proven identical to serial (no fixture cross-talk). Coverage
44
+ narrowing is restored on this path (each mutant runs only its covering tests;
45
+ a mutant on an uncovered line is `no_coverage`), so the daemon score is
46
+ comparable to the in-process `--rails` score. Opt in with `--rails --daemon`
47
+ (also `daemon: true` in `.mutineer.yml`); `--daemon` can't be combined with
48
+ `--test-command`. **SQLite** today (hermetic, CI-proven); **Postgres**
49
+ per-worker provisioning is in progress (#34/#35). The gem core stays Prism +
50
+ stdlib, zero runtime dependencies — worker-DB routing uses the app's own
51
+ ActiveRecord.
52
+
9
53
  ## [0.10.0] - 2026-07-02
10
54
 
11
55
  ### Added
@@ -200,6 +244,9 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
200
244
  - `.mutineer.yml` configuration (CLI > config > default precedence).
201
245
  - Byte-correct source handling for multibyte (UTF-8) sources.
202
246
 
247
+ [0.11.1]: https://github.com/davidteren/mutineer/releases/tag/v0.11.1
248
+ [0.11.0]: https://github.com/davidteren/mutineer/releases/tag/v0.11.0
249
+ [0.10.0]: https://github.com/davidteren/mutineer/releases/tag/v0.10.0
203
250
  [0.9.1]: https://github.com/davidteren/mutineer/releases/tag/v0.9.1
204
251
  [0.9.0]: https://github.com/davidteren/mutineer/releases/tag/v0.9.0
205
252
  [0.8.0]: https://github.com/davidteren/mutineer/releases/tag/v0.8.0
data/README.md CHANGED
@@ -57,6 +57,7 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
57
57
  | `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
58
58
  | `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
59
59
  | `--test-command CMD` | Run the suite as a subprocess in the app's own runtime (for apps on Ruby < 3.4); `CMD` must contain `%{files}`. See [Apps on Ruby < 3.4](#apps-on-ruby--34) |
60
+ | `--daemon` | Boot the app once in a persistent daemon and fork per mutant, with per-worker DB isolation so `--jobs N` is safe under Rails (needs `--rails`/`--boot`; not with `--test-command`). See [the daemon backend](#faster-parallel-safe-rails-the---daemon-backend) |
60
61
  | `--format human\|json\|html` | Report format (default: human; `html` is a self-contained file) |
61
62
  | `--output FILE` | Write the report to FILE instead of stdout |
62
63
  | `--dry-run` | List candidate mutations without executing (honors suppression) |
@@ -104,6 +105,33 @@ Add Mutineer to your Gemfile's test group:
104
105
  gem "mutineer", group: :test, require: false
105
106
  ```
106
107
 
108
+ ### Faster, parallel-safe Rails (the `--daemon` backend)
109
+
110
+ `--rails` boots your app once but runs mutants **serially** — parallel `--jobs`
111
+ under Rails is unsafe, because every worker shares one test database and clobbers
112
+ the others' fixtures. `--daemon` fixes both: it boots the app once in a persistent
113
+ helper and forks per mutant, and gives **each parallel worker its own database**,
114
+ so `--jobs N` is safe and its verdicts are proven identical to a serial run.
115
+
116
+ ```sh
117
+ RAILS_ENV=test bundle exec mutineer run \
118
+ app/models/order.rb --test test/models/order_test.rb \
119
+ --rails --daemon --jobs 4
120
+ ```
121
+
122
+ - **One boot, forked per mutant** — restores the shared-boot speed.
123
+ - **Coverage-guided** — each mutant runs only its covering tests (like `--rails`);
124
+ a mutant on an uncovered line is `no_coverage`, so the score stays comparable to
125
+ the in-process `--rails` score.
126
+ - **Safe `--jobs N`** — each worker routes to its own copy of the test database, so
127
+ parallel verdicts equal serial (no fixture cross-talk).
128
+ - **One backend at a time** — `--daemon` can't be combined with `--test-command`
129
+ (choose one), and it needs an app to boot (`--rails` or `--boot`).
130
+
131
+ Status: **SQLite** today (hermetic, CI-proven). **Postgres** per-worker
132
+ provisioning is in progress (#34/#35); until it lands, use `--daemon` with a
133
+ SQLite test database, or drop `--jobs` to run serially on other adapters.
134
+
107
135
  ### Apps on Ruby < 3.4
108
136
 
109
137
  Mutineer's own process needs Ruby ≥ 3.4 (it parses with stdlib Prism), and the
@@ -136,7 +164,8 @@ Tradeoffs (Phase 1) — this path is correct but not free:
136
164
  is scored as a kill. Mutineer prints this caveat on every run and aborts up
137
165
  front (a "smoke check") if your unmutated suite isn't green.
138
166
  - **Reload strategy only** (`--strategy redefine` is rejected on this path) and
139
- **serial** (`--jobs` is forced to 1 safe parallelism is tracked in #26).
167
+ **serial** (`--jobs` is forced to 1). For apps on Ruby 3.4, `--daemon` gives
168
+ safe parallelism instead (see [the daemon backend](#faster-parallel-safe-rails-the---daemon-backend)).
140
169
 
141
170
  ## Suppressing equivalent mutants
142
171
 
data/lib/mutineer/cli.rb CHANGED
@@ -51,6 +51,9 @@ module Mutineer
51
51
  subprocess (for apps on Ruby < 3.4). CMD must contain
52
52
  %{files} (expands to the --test paths). Env is inherited,
53
53
  e.g. RAILS_ENV=test mutineer run ... --test-command "..."
54
+ --daemon Boot the app ONCE in a persistent daemon and fork per
55
+ mutant, with per-worker DB isolation so --jobs N is safe
56
+ under Rails (needs --rails/--boot; not with --test-command)
54
57
  --format human|json|html Report format (default: human)
55
58
  --output FILE Write the report to FILE instead of stdout
56
59
  --dry-run List mutations without executing
@@ -90,12 +93,20 @@ module Mutineer
90
93
  end
91
94
  o.on("--list-operators") { show_operators = true }
92
95
  o.on("--dry-run") { opts[:dry_run] = true }
93
- o.on("--fail-fast") { opts[:fail_fast] = true }
96
+ o.on("--fail-fast") { opts[:fail_fast] = true; explicit << :fail_fast }
94
97
  o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
95
98
  o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
96
99
  o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
97
100
  o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
98
- o.on("--threshold FLOAT") { |v| opts[:threshold] = v.to_f; explicit << :threshold }
101
+ o.on("--threshold FLOAT") do |v|
102
+ f = Float(v, exception: false)
103
+ if f.nil?
104
+ warn "mutineer: --threshold requires a number between 0 and 100 (got: #{v.inspect})"
105
+ exit 2
106
+ end
107
+ opts[:threshold] = f
108
+ explicit << :threshold
109
+ end
99
110
  o.on("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
100
111
  o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
101
112
  o.on("--framework NAME") { |v| opts[:framework] = v; explicit << :framework }
@@ -112,6 +123,9 @@ module Mutineer
112
123
  # #27: run the target suite as a subprocess in the app's OWN runtime so
113
124
  # mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
114
125
  o.on("--test-command CMD") { |v| opts[:test_command] = v; explicit << :test_command }
126
+ # #26/#27 Phase 2: boot the app ONCE in a persistent daemon and fork per
127
+ # mutant, with per-worker DB isolation so --jobs N is safe under Rails.
128
+ o.on("--daemon") { opts[:daemon] = true; explicit << :daemon }
115
129
  end
116
130
 
117
131
  begin
@@ -247,15 +261,16 @@ module Mutineer
247
261
  preflight_output!(config.output) if config.output
248
262
  preflight_baseline!(config.baseline) if config.baseline
249
263
 
250
- # #11: when --test is omitted, infer each source's test by convention so the
251
- # boot-once/fork-per-test core (which pairs empirically by coverage) gets a
252
- # populated config.tests. Runs after every flag/usage check above so a
253
- # mistyped flag still reports the flag; skipped under --dry-run (no tests
254
- # needed). validate_paths! then sees the inferred (real) tests.
264
+ # When --test is omitted, infer each source's test by convention. Autopair
265
+ # also re-detects framework from inferred tests when --framework was not set.
255
266
  autopair!(config, explicit) unless config.dry_run
256
267
 
257
- # Boot mode does no coverage selection every mutant runs the given tests
258
- # so at least one --test file is mandatory (there is nothing to select from).
268
+ # Daemon validation runs AFTER autopair so auto-inferred *_spec.rb tests
269
+ # cannot bypass the RSpec rejection (framework would still be minitest if we
270
+ # validated before discovery).
271
+ validate_daemon!(config) if config.daemon
272
+
273
+ # Boot mode needs at least one --test file (nothing to select from otherwise).
259
274
  if config.boot && config.tests.empty?
260
275
  warn "mutineer: --boot/--rails requires at least one --test file"
261
276
  exit 2
@@ -300,6 +315,36 @@ module Mutineer
300
315
  config.jobs = 1
301
316
  end
302
317
 
318
+ # --daemon selects the persistent-daemon backend (boot once, fork per mutant,
319
+ # per-worker DB isolation on SQLite). Usage errors exit 2: cannot combine with
320
+ # --test-command; requires --rails or --boot; minitest only; reload strategy only
321
+ # (redefine needs a shared VM surgical path the daemon does not ship).
322
+ #
323
+ # @api private
324
+ # @param config [Mutineer::Config] run configuration.
325
+ # @return [void]
326
+ def self.validate_daemon!(config)
327
+ if config.test_command
328
+ warn "mutineer: choose one backend — --daemon and --test-command cannot be combined"
329
+ exit 2
330
+ end
331
+ unless config.rails || config.boot
332
+ warn "mutineer: --daemon needs an app to boot; add --rails (or --boot FILE)"
333
+ exit 2
334
+ end
335
+ if config.framework == "rspec"
336
+ warn "mutineer: --daemon supports only --framework minitest " \
337
+ "(rspec is not implemented on the daemon path yet)"
338
+ exit 2
339
+ end
340
+ return if config.strategy == "reload"
341
+
342
+ # --rails defaults strategy to redefine; daemon always whole-file loads.
343
+ warn "[mutineer] --daemon uses --strategy reload " \
344
+ "(redefine is not supported on the daemon path); forcing reload."
345
+ config.strategy = "reload"
346
+ end
347
+
303
348
  # --since needs a real git repo and a resolvable ref; either failure is a
304
349
  # usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
305
350
  # Validates the --since ref.
@@ -427,17 +472,18 @@ module Mutineer
427
472
  reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
428
473
  format: config.format, output: config.output, baseline: delta)
429
474
 
430
- # #27/KTD-6: warn (stderr, so it never pollutes json/html) that an external
431
- # run's score is not comparable to an in-process run it has no coverage
432
- # narrowing, so uncovered mutants count as survivors, and an infra failure is
433
- # scored as a kill (upper bound).
475
+ # Warn (stderr, so it never pollutes json/html) that an external run's score
476
+ # is not comparable to an in-process run: no coverage narrowing (uncovered
477
+ # mutants count as survivors), and an infra failure is scored as a kill
478
+ # (upper bound). Daemon coverage fallback warnings are emitted from the runner
479
+ # only when the map is unavailable — not on every --daemon run.
434
480
  if config.test_command
435
481
  warn "[mutineer] --test-command score is an upper bound, not comparable to an " \
436
482
  "in-process run: no coverage narrowing (uncovered mutants count as survivors) " \
437
483
  "and an infra failure is scored as a kill."
438
484
  end
439
485
 
440
- # #14: nudge toward the opt-in tier-2 operators (human report only — never
486
+ # Nudge toward the opt-in tier-2 operators (human report only — never
441
487
  # pollute JSON output).
442
488
  if !%w[json html].include?(config.format) && (hint = tier2_hint(config.operators))
443
489
  puts hint
@@ -464,50 +510,37 @@ module Mutineer
464
510
  "enable with --operators <list>."
465
511
  end
466
512
 
467
- # Runs dry-run mode.
513
+ # Runs dry-run mode. Reuses Runner.collect_jobs (+ filter_since) so the
514
+ # candidate list cannot drift from a real run's job selection.
468
515
  #
469
516
  # @param config [Mutineer::Config] run configuration.
470
517
  # @return [void]
471
518
  def self.dry_run(config)
472
519
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
473
- sources = {}
520
+ jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
521
+ # Narrow jobs and ignored the same way so the summary matches the printed list.
522
+ if config.since
523
+ jobs = Runner.filter_since(jobs, source_map, config)
524
+ ignored_jobs = ignored_results.map { |r| [r.subject, r.mutation, r.id] }
525
+ ignored = Runner.filter_since(ignored_jobs, source_map, config).size
526
+ else
527
+ ignored = ignored_results.size
528
+ end
529
+
474
530
  per_operator = Hash.new(0)
475
531
  skipped = 0
476
- ignored = 0
477
- ignore_set = config.ignore.to_set
478
-
479
- # --since narrows the preview to changed lines too, so `--dry-run --since`
480
- # shows exactly what a real `--since` run would mutate.
481
- changed = if config.since
482
- ChangedLines.for(ref: config.since, files: config.sources,
483
- project_root: config.project_root)
484
- end
485
-
486
- Project.discover(config.sources, only: config.only).each do |subject|
487
- source = (sources[subject.file] ||= Parser.parse_file(subject.file).source.source)
488
- # #22: honor suppression so the preview matches what a real run mutates.
489
- # Mirror execute's per-subject shape (ids need the full mutation list).
490
- disabled = Runner.suppress_map(source)
491
- mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
492
- ids = MutantId.for_subject(subject, source, mutations)
493
- mutations.each_with_index do |mutation, i|
494
- unless mutation.valid?(source)
495
- skipped += 1
496
- next
497
- end
498
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
499
- next if changed && !changed[File.expand_path(subject.file, config.project_root)]&.include?(line)
500
-
501
- if Runner.suppressed?(mutation.operator, line, ids[i], disabled, ignore_set)
502
- ignored += 1
503
- next
504
- end
505
-
506
- per_operator[mutation.operator] += 1
507
- original = source.byteslice(mutation.start_offset...mutation.end_offset)
508
- puts "[#{mutation.operator}] #{subject.qualified_name} " \
509
- "#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
532
+ jobs.each do |subject, mutation, _id|
533
+ source = source_map[subject.file]
534
+ unless mutation.valid?(source)
535
+ skipped += 1
536
+ next
510
537
  end
538
+
539
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
540
+ per_operator[mutation.operator] += 1
541
+ original = source.byteslice(mutation.start_offset...mutation.end_offset)
542
+ puts "[#{mutation.operator}] #{subject.qualified_name} " \
543
+ "#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
511
544
  end
512
545
 
513
546
  total = per_operator.values.sum
@@ -25,9 +25,8 @@ module Mutineer
25
25
  :cache_dir, :project_root, :load_paths,
26
26
  :jobs, :format, :output, :strategy, :require_paths,
27
27
  :boot, :rails, :since, :framework, :verbose, :ignore,
28
- # :daemon / :daemon_timeout are NOT user-facing yet no CLI flag or KNOWN_KEYS
29
- # entry until the Phase 2c `--daemon` unit lands (which adds the flag + a `to_i`
30
- # coerce + KNOWN_KEYS). For now they're set programmatically (tests/Runner).
28
+ # :daemon is user-facing as of U8 (--daemon flag + KNOWN_KEYS + boolean coerce).
29
+ # :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
31
30
  :baseline, :baseline_epsilon, :fail_fast, :test_command,
32
31
  :daemon, :daemon_timeout,
33
32
  keyword_init: true
@@ -35,7 +34,7 @@ module Mutineer
35
34
  # Config file name.
36
35
  CONFIG_FILE = ".mutineer.yml"
37
36
  # Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
38
- KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast test_command].freeze
37
+ KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast test_command daemon].freeze
39
38
 
40
39
  def initialize(**kwargs)
41
40
  super
@@ -113,17 +112,21 @@ module Mutineer
113
112
  file_hash.each { |k, v| merged[k] = v unless explicit.include?(k) }
114
113
  config = new(**merged)
115
114
 
116
- # --rails sugar: boot config/environment and prefer the surgical (redefine)
117
- # strategy, which avoids writing tempfiles into the app tree and Zeitwerk
118
- # reload hazards. An explicit --strategy always wins.
115
+ # --rails sugar: boot config/environment. Prefer redefine only for the
116
+ # in-process path (daemon is whole-file reload only). In-process --rails
117
+ # shares one test database force serial unless --daemon.
119
118
  if config.rails
120
119
  config.boot ||= "config/environment"
121
- config.strategy = "redefine" unless explicit.include?(:strategy)
122
- # #12: parallel mutant forks share one database; transactional-fixture
123
- # setup/teardown across processes contends and deadlocks. Default to
124
- # serial under --rails; an explicit --jobs N opts back into parallelism
125
- # (with the per-worker DB-isolation that implies).
126
- config.jobs = 1 unless explicit.include?(:jobs)
120
+ unless config.daemon || explicit.include?(:strategy)
121
+ config.strategy = "redefine"
122
+ end
123
+ unless config.daemon
124
+ if config.jobs.to_i > 1
125
+ warn "[mutineer] --rails without --daemon runs serially (shared test DB); " \
126
+ "forcing --jobs 1. Use --daemon for safe --jobs N."
127
+ end
128
+ config.jobs = 1
129
+ end
127
130
  end
128
131
 
129
132
  # Auto-detect the framework only when neither CLI nor config file set it
@@ -163,12 +166,20 @@ module Mutineer
163
166
  case known_key
164
167
  when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
165
168
  when "jobs" then value.to_i
166
- when "threshold" then value.to_f
169
+ when "threshold"
170
+ f = Float(value, exception: false)
171
+ if f.nil?
172
+ raise ConfigError, "#{file_name}: threshold must be a number between 0 and 100 " \
173
+ "(got: #{value.inspect})"
174
+ end
175
+ f
167
176
  when "require" then Array(value).map(&:to_s)
168
177
  when "boot" then value.to_s
169
178
  when "framework" then value.to_s
170
179
  when "rails" then value == true || value.to_s == "true"
180
+ when "daemon" then value == true || value.to_s == "true"
171
181
  when "verbose" then value == true || value.to_s == "true"
182
+ when "fail_fast" then value == true || value.to_s == "true"
172
183
  when "ignore" then Array(value).map(&:to_s)
173
184
  when "baseline" then value.to_s
174
185
  when "test_command" then value.to_s
@@ -21,7 +21,24 @@ module Mutineer
21
21
  class CoverageMap
22
22
  DEFAULT_CAPTURE_TIMEOUT = 120 # seconds, per coverage subprocess (R3)
23
23
 
24
- attr_reader :project_root, :failed_test_files, :phase_a_ran
24
+ attr_reader :project_root, :failed_test_files, :phase_a_ran, :map
25
+
26
+ # Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the map
27
+ # app-side and ships `map` + `failed_test_files` over IPC; the tool reconstructs it
28
+ # here for per-mutant selection). Skips the capture machinery entirely — only the
29
+ # three fields #tests_for / #method_uncapturable? read are set (U7).
30
+ #
31
+ # @param map [Hash] the "file:line" => [test_files] map.
32
+ # @param failed_test_files [Array<String>] test files whose capture failed.
33
+ # @param project_root [String] project root (for path relativization).
34
+ # @return [Mutineer::CoverageMap] a query-only map.
35
+ def self.from_data(map:, failed_test_files:, project_root:)
36
+ instance = allocate
37
+ instance.instance_variable_set(:@map, map || {})
38
+ instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
39
+ instance.instance_variable_set(:@project_root, project_root)
40
+ instance
41
+ end
25
42
 
26
43
  def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
27
44
  load_paths: ["lib"], project_root: Dir.pwd,
@@ -54,9 +71,9 @@ module Mutineer
54
71
  # the booted parent instead. Inverts into the same map #tests_for reads, and
55
72
  # reuses the digest cache (the digest mixes in the boot file so a boot cache
56
73
  # never collides with a standalone one).
57
- def build_via_fork(rails: false)
74
+ def build_via_fork(after_fork: nil)
58
75
  warn_external_sources
59
- cached_or { run_phase_a_via_fork(rails: rails) }
76
+ cached_or { run_phase_a_via_fork(after_fork: after_fork) }
60
77
  end
61
78
 
62
79
  # Phase B lookup: the test files that cover `file:line`, or [] when none do.
@@ -159,7 +176,7 @@ module Mutineer
159
176
  # per-source coverage counts. record() inverts them exactly as the subprocess
160
177
  # path does. ponytail: serial fork (one test at a time) — boot apps fork
161
178
  # cheaply via COW and per-test isolation matters more than throughput here.
162
- def run_phase_a_via_fork(rails:)
179
+ def run_phase_a_via_fork(after_fork:)
163
180
  @phase_a_ran = true
164
181
  @map = {}
165
182
  @failed_test_files = []
@@ -169,7 +186,7 @@ module Mutineer
169
186
  # Tri-state payload (KTD-1): Hash = coverage, String = error diagnostic
170
187
  # from the child, nil = pipe gone / empty.
171
188
  # ponytail/#9: this String diagnostic is what #9 turns into an :uncapturable status.
172
- case (coverage = fork_capture(absolute(test_path), abs_sources, rails))
189
+ case (coverage = fork_capture(absolute(test_path), abs_sources, after_fork))
173
190
  when Hash then record(coverage, test_path)
174
191
  when String
175
192
  fail_test(test_path, @verbose ? "fork capture failed: #{coverage}" :
@@ -182,7 +199,7 @@ module Mutineer
182
199
  # Fork the booted parent, run one test under the inherited Coverage, and
183
200
  # return its per-source counts hash (or nil on failure). Reuses the same
184
201
  # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
185
- def fork_capture(abs_test, abs_sources, rails)
202
+ def fork_capture(abs_test, abs_sources, after_fork)
186
203
  rd, wr = IO.pipe
187
204
  # #19: Marshal output is binary — an un-binmoded pipe can raise
188
205
  # Encoding::UndefinedConversionError on write, which the child's rescue then
@@ -193,7 +210,10 @@ module Mutineer
193
210
  rd.close
194
211
  payload =
195
212
  begin
196
- Runner.send(:reconnect_active_record) if rails
213
+ # Fork-safety hook: the in-process path reconnects AR; the daemon routes
214
+ # to its worker DB. Nil (non-Rails) = no-op. Injected so this file needs
215
+ # neither Runner (Prism) nor Rails.
216
+ after_fork&.call
197
217
  Coverage.result(clear: true, stop: false) # discard pre-test delta
198
218
  TestRunners.for(@framework).run([abs_test])
199
219
  # lines:true yields {file => {lines: [...]}}; reduce to the counts
@@ -55,15 +55,17 @@ module Mutineer
55
55
  # @param payload [Hash] {"code" => mutated ruby, "source_file" => path}.
56
56
  # @param tests [Array<String>] covering test file paths.
57
57
  # @param timeout [Numeric] per-mutant wall-clock timeout (seconds).
58
+ # @param worker [Integer] worker slot; the daemon routes the fork to
59
+ # `<db>-<worker>` (#26 isolation). Defaults to 0 (serial in U5).
58
60
  # @return [String] one of survived/killed/error/timeout.
59
- def request(id:, payload:, tests:, timeout:)
61
+ def request(id:, payload:, tests:, timeout:, worker: 0)
60
62
  # A crash can surface on the WRITE (daemon died idle between requests →
61
63
  # Errno::EPIPE) as well as the read (EOF), so guard both: either way, respawn
62
64
  # for future mutants and score THIS one error (re-running a crash-causing
63
65
  # mutant could loop). Never let a dead pipe abort the whole run.
64
66
  reply =
65
67
  begin
66
- send_line("id" => id, "payload" => payload, "tests" => tests, "timeout" => timeout)
68
+ send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
67
69
  read_line
68
70
  rescue Errno::EPIPE, IOError
69
71
  nil
@@ -74,6 +76,19 @@ module Mutineer
74
76
  "error"
75
77
  end
76
78
 
79
+ # #26/U7: ask the daemon to build the coverage map app-side and return it.
80
+ # One-shot control message (no id). Returns `{"map"=>..., "failed_test_files"=>...}`
81
+ # (possibly with an `"error"`), or nil if the daemon vanished — the caller then
82
+ # falls back to running the full test set (no narrowing) rather than mis-scoring.
83
+ #
84
+ # @return [Hash, nil] the coverage payload, or nil on a dead pipe.
85
+ def coverage
86
+ send_line("cmd" => "coverage")
87
+ read_line
88
+ rescue Errno::EPIPE, IOError
89
+ nil
90
+ end
91
+
77
92
  # Graceful shutdown; leaves no orphaned daemon/child.
78
93
  #
79
94
  # @return [void]