mutineer 0.11.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: 2dd1784f527b49eaed8d13a3e03c59c8a30082a44cf625f3a6ce1f252ffb3a60
4
- data.tar.gz: 164d53165ea6f85c31158783ea43f8f2c88c3df058240d748df01f960da23897
3
+ metadata.gz: 42d278a96730cdf9616492a720c701ba9119195299f64fa74b339b3737e4e545
4
+ data.tar.gz: ef2148e5752ea9790403ca256e4bc6672b5e8c2656289d74b9cd3ffcd067f475
5
5
  SHA512:
6
- metadata.gz: a8bcaae29c133ca1ac0f9da31dc143b7b5deeef347104e209386cf6029252e77586d1240440e97bcec082e9e11a355087ffda91e7ca5495de85ac0f6bf1c2155
7
- data.tar.gz: 06f108bfbb0f4b32318ca120e086ce88516b036ebc622e37b81963f4f93b7a87d5cdb6403b95cd69aa7a196e910718ba52b632d376a10f2763bf05f9f94a8fc8
6
+ metadata.gz: 82879be78e820ef184dca76bf52ad883298f3c0e8becdb6344061b32321f44945464025c6f7d8064e9b649486b6b5f258b60b43baf0ec6f0d38356b314af5793
7
+ data.tar.gz: 67399daaa12b9b312a4ad0fcad144ae763783d2f5994e474b1303ad7ed0ddd5c5d42fba5b39c750115a0614356b4e4a2593238b91e39eee38edd96981c4afa5a
data/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ 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
+
9
36
  ## [0.11.0] - 2026-07-02
10
37
 
11
38
  ### Added
@@ -217,6 +244,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
217
244
  - `.mutineer.yml` configuration (CLI > config > default precedence).
218
245
  - Byte-correct source handling for multibyte (UTF-8) sources.
219
246
 
247
+ [0.11.1]: https://github.com/davidteren/mutineer/releases/tag/v0.11.1
220
248
  [0.11.0]: https://github.com/davidteren/mutineer/releases/tag/v0.11.0
221
249
  [0.10.0]: https://github.com/davidteren/mutineer/releases/tag/v0.10.0
222
250
  [0.9.1]: https://github.com/davidteren/mutineer/releases/tag/v0.9.1
data/lib/mutineer/cli.rb CHANGED
@@ -93,12 +93,20 @@ module Mutineer
93
93
  end
94
94
  o.on("--list-operators") { show_operators = true }
95
95
  o.on("--dry-run") { opts[:dry_run] = true }
96
- o.on("--fail-fast") { opts[:fail_fast] = true }
96
+ o.on("--fail-fast") { opts[:fail_fast] = true; explicit << :fail_fast }
97
97
  o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
98
98
  o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
99
99
  o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
100
100
  o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
101
- 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
102
110
  o.on("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
103
111
  o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
104
112
  o.on("--framework NAME") { |v| opts[:framework] = v; explicit << :framework }
@@ -248,21 +256,21 @@ module Mutineer
248
256
  end
249
257
 
250
258
  validate_test_command!(config) if config.test_command
251
- validate_daemon!(config, explicit) if config.daemon
252
259
 
253
260
  validate_since!(config) if config.since
254
261
  preflight_output!(config.output) if config.output
255
262
  preflight_baseline!(config.baseline) if config.baseline
256
263
 
257
- # #11: when --test is omitted, infer each source's test by convention so the
258
- # boot-once/fork-per-test core (which pairs empirically by coverage) gets a
259
- # populated config.tests. Runs after every flag/usage check above so a
260
- # mistyped flag still reports the flag; skipped under --dry-run (no tests
261
- # 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.
262
266
  autopair!(config, explicit) unless config.dry_run
263
267
 
264
- # Boot mode does no coverage selection every mutant runs the given tests
265
- # 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).
266
274
  if config.boot && config.tests.empty?
267
275
  warn "mutineer: --boot/--rails requires at least one --test file"
268
276
  exit 2
@@ -307,28 +315,34 @@ module Mutineer
307
315
  config.jobs = 1
308
316
  end
309
317
 
310
- # #26/#27 Phase 2 (U8): --daemon selects the persistent-daemon backend (boot once,
311
- # fork per mutant, per-worker DB isolation). Two usage errors, both exit 2 (KTD-10):
312
- # it cannot be combined with --test-command (choose ONE backend never silently
313
- # pick one), and it requires an app to boot (--rails or --boot). Under Rails,
314
- # Config.resolve already keeps --jobs serial unless the user explicitly asks for
315
- # parallelism, and each worker gets its own SQLite database on demand — so there is
316
- # no "missing worker DB" precondition to check here for SQLite. Postgres worker-DB
317
- # provisioning + its missing-DB error (KTD-9) arrives with the Postgres adapter (U10).
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).
318
322
  #
319
323
  # @api private
320
324
  # @param config [Mutineer::Config] run configuration.
321
- # @param explicit [Set<Symbol>] explicit CLI fields.
322
325
  # @return [void]
323
- def self.validate_daemon!(config, _explicit = Set.new)
326
+ def self.validate_daemon!(config)
324
327
  if config.test_command
325
328
  warn "mutineer: choose one backend — --daemon and --test-command cannot be combined"
326
329
  exit 2
327
330
  end
328
- return if config.rails || config.boot
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"
329
341
 
330
- warn "mutineer: --daemon needs an app to boot; add --rails (or --boot FILE)"
331
- exit 2
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"
332
346
  end
333
347
 
334
348
  # --since needs a real git repo and a resolvable ref; either failure is a
@@ -458,26 +472,18 @@ module Mutineer
458
472
  reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
459
473
  format: config.format, output: config.output, baseline: delta)
460
474
 
461
- # #27/KTD-6: warn (stderr, so it never pollutes json/html) that an external
462
- # run's score is not comparable to an in-process run it has no coverage
463
- # narrowing, so uncovered mutants count as survivors, and an infra failure is
464
- # 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.
465
480
  if config.test_command
466
481
  warn "[mutineer] --test-command score is an upper bound, not comparable to an " \
467
482
  "in-process run: no coverage narrowing (uncovered mutants count as survivors) " \
468
483
  "and an infra failure is scored as a kill."
469
484
  end
470
485
 
471
- # #26/U8: same disclosure as --test-command above the daemon backend has no
472
- # coverage narrowing yet (Phase 2c), so this score is a lower bound (uncovered
473
- # mutants count as survivors) and not comparable to an in-process run.
474
- if config.daemon
475
- warn "[mutineer] --daemon score is a lower bound, not comparable to an " \
476
- "in-process run: no coverage narrowing yet (uncovered mutants count as " \
477
- "survivors)."
478
- end
479
-
480
- # #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
481
487
  # pollute JSON output).
482
488
  if !%w[json html].include?(config.format) && (hint = tier2_hint(config.operators))
483
489
  puts hint
@@ -504,50 +510,37 @@ module Mutineer
504
510
  "enable with --operators <list>."
505
511
  end
506
512
 
507
- # 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.
508
515
  #
509
516
  # @param config [Mutineer::Config] run configuration.
510
517
  # @return [void]
511
518
  def self.dry_run(config)
512
519
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
513
- 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
+
514
530
  per_operator = Hash.new(0)
515
531
  skipped = 0
516
- ignored = 0
517
- ignore_set = config.ignore.to_set
518
-
519
- # --since narrows the preview to changed lines too, so `--dry-run --since`
520
- # shows exactly what a real `--since` run would mutate.
521
- changed = if config.since
522
- ChangedLines.for(ref: config.since, files: config.sources,
523
- project_root: config.project_root)
524
- end
525
-
526
- Project.discover(config.sources, only: config.only).each do |subject|
527
- source = (sources[subject.file] ||= Parser.parse_file(subject.file).source.source)
528
- # #22: honor suppression so the preview matches what a real run mutates.
529
- # Mirror execute's per-subject shape (ids need the full mutation list).
530
- disabled = Runner.suppress_map(source)
531
- mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
532
- ids = MutantId.for_subject(subject, source, mutations)
533
- mutations.each_with_index do |mutation, i|
534
- unless mutation.valid?(source)
535
- skipped += 1
536
- next
537
- end
538
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
539
- next if changed && !changed[File.expand_path(subject.file, config.project_root)]&.include?(line)
540
-
541
- if Runner.suppressed?(mutation.operator, line, ids[i], disabled, ignore_set)
542
- ignored += 1
543
- next
544
- end
545
-
546
- per_operator[mutation.operator] += 1
547
- original = source.byteslice(mutation.start_offset...mutation.end_offset)
548
- puts "[#{mutation.operator}] #{subject.qualified_name} " \
549
- "#{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
550
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}`"
551
544
  end
552
545
 
553
546
  total = per_operator.values.sum
@@ -112,17 +112,21 @@ module Mutineer
112
112
  file_hash.each { |k, v| merged[k] = v unless explicit.include?(k) }
113
113
  config = new(**merged)
114
114
 
115
- # --rails sugar: boot config/environment and prefer the surgical (redefine)
116
- # strategy, which avoids writing tempfiles into the app tree and Zeitwerk
117
- # 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.
118
118
  if config.rails
119
119
  config.boot ||= "config/environment"
120
- config.strategy = "redefine" unless explicit.include?(:strategy)
121
- # #12: parallel mutant forks share one database; transactional-fixture
122
- # setup/teardown across processes contends and deadlocks. Default to
123
- # serial under --rails; an explicit --jobs N opts back into parallelism
124
- # (with the per-worker DB-isolation that implies).
125
- 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
126
130
  end
127
131
 
128
132
  # Auto-detect the framework only when neither CLI nor config file set it
@@ -162,13 +166,20 @@ module Mutineer
162
166
  case known_key
163
167
  when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
164
168
  when "jobs" then value.to_i
165
- 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
166
176
  when "require" then Array(value).map(&:to_s)
167
177
  when "boot" then value.to_s
168
178
  when "framework" then value.to_s
169
179
  when "rails" then value == true || value.to_s == "true"
170
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
@@ -127,6 +127,8 @@ module Mutineer
127
127
  @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
128
128
  schema = cfg["schema"] && File.expand_path(cfg["schema"])
129
129
  @schema_path = schema if schema && File.exist?(schema)
130
+ # Schema is loaded once per worker slot on first use (not every mutant fork).
131
+ @schema_ready = {}
130
132
  rescue LoadError => e
131
133
  @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
132
134
  @worker_db = nil
@@ -165,18 +167,18 @@ module Mutineer
165
167
  def run_mutant(req)
166
168
  timeout = req.fetch("timeout", 30)
167
169
  worker = req.fetch("worker", 0)
170
+ # Load schema until the first killed/survived fork for this worker slot.
171
+ schema_for_fork = (@worker_db && @schema_path && !@schema_ready[worker]) ? @schema_path : nil
168
172
  pid = fork do
169
- # New process group so a per-fork timeout can SIGKILL the whole subtree
170
- # (carries the Phase-1 pgroup discipline), and silence the child's stdout so
171
- # test-framework output can never corrupt the IPC pipe (KTD-6).
173
+ # New process group so a per-fork timeout can SIGKILL the whole subtree,
174
+ # and silence the child's stdout so test output never corrupts the IPC pipe.
172
175
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
173
176
  $stdout.reopen(File::NULL, "w")
174
177
  code =
175
178
  begin
176
- # Route THIS fork at its own worker database before any test loads, so
177
- # concurrent workers can't clobber each other's fixtures (#26). A routing
178
- # failure raises here and is scored `error` below, never a false verdict.
179
- @worker_db&.after_fork(worker, @schema_path)
179
+ # Route THIS fork at its own worker database before any test loads.
180
+ # A routing failure raises here and is scored `error`, never a false verdict.
181
+ @worker_db&.after_fork(worker, schema_for_fork)
180
182
  apply_payload(req["payload"])
181
183
  run_tests(Array(req["tests"]))
182
184
  rescue Exception => e # rubocop:disable Lint/RescueException
@@ -186,6 +188,10 @@ module Mutineer
186
188
  exit!(code)
187
189
  end
188
190
  verdict = wait_verdict(pid, timeout)
191
+ # Mark ready only when the child finished cleanly after schema load
192
+ # (killed/survived). Timeout can interrupt mid-load_schema; error is a
193
+ # routing failure — both leave the slot unready so the next fork reloads.
194
+ @schema_ready[worker] = true if schema_for_fork && %w[killed survived].include?(verdict)
189
195
  # A SIGKILLed timeout child skipped its Tempfile unlink — sweep the orphan so
190
196
  # it can't outlive the run or trip Zeitwerk on a later fork.
191
197
  sweep_temps if verdict == "timeout"
@@ -222,7 +228,14 @@ module Mutineer
222
228
  rescue Errno::ESRCH, Errno::EPERM
223
229
  Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
224
230
  end
225
- Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
231
+ begin
232
+ _reaped, status = Process.waitpid2(pid)
233
+ if status && status.exited? && !status.signaled?
234
+ return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
235
+ end
236
+ rescue Errno::ECHILD
237
+ # already reaped
238
+ end
226
239
  return "timeout"
227
240
  end
228
241
  sleep POLL
@@ -62,6 +62,12 @@ module Mutineer
62
62
  Result.timeout
63
63
  else # :exited
64
64
  return Result.survived if code&.zero?
65
+ # Signal death (nil exitstatus) is infrastructure, not a suite assertion
66
+ # failure — match Isolation/daemon (error), do not inflate kill rate.
67
+ if code.nil?
68
+ warn output if verbose && !output.empty?
69
+ return Result.error("test-command terminated by signal")
70
+ end
65
71
 
66
72
  warn output if verbose && !output.empty?
67
73
  Result.killed
@@ -151,7 +157,13 @@ module Mutineer
151
157
  rescue Errno::ESRCH, Errno::EPERM
152
158
  Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
153
159
  end
154
- Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
160
+ begin
161
+ _reaped, status = Process.waitpid2(pid)
162
+ # Finished cleanly between poll and kill — honor real exit code.
163
+ return [:exited, status.exitstatus] if status && status.exited? && !status.signaled?
164
+ rescue Errno::ECHILD
165
+ # already reaped
166
+ end
155
167
  return [:timeout, nil]
156
168
  end
157
169
  sleep POLL
@@ -31,6 +31,9 @@ module Mutineer
31
31
  # @return [Mutineer::Result] result from the child process.
32
32
  def self.run(timeout: DEFAULT_TIMEOUT)
33
33
  pid = fork do
34
+ # Own process group so a timeout kill can reap grandchildren (match
35
+ # daemon/external backends). Best-effort: if setpgid fails, kill the pid.
36
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
34
37
  code = 0
35
38
  begin
36
39
  result = yield
@@ -48,20 +51,27 @@ module Mutineer
48
51
  exit!(code)
49
52
  end
50
53
 
51
- # Single-threaded deadline poll (R2): we are the ONLY caller of waitpid
52
- # on this pid, so we never reap-then-kill. We SIGKILL only after WNOHANG
53
- # shows the child is still alive past the deadline — so the kill can
54
- # never hit a reaped/recycled pid. Timeout is a parent-side fact
55
- # (deadline reached), not status.signaled? (which is true for ANY signal
56
- # death, e.g. SIGSEGV).
54
+ # Single-threaded deadline poll: we are the ONLY caller of waitpid on this
55
+ # pid, so we never reap-then-kill. SIGKILL the process group only after
56
+ # WNOHANG shows the child is still alive past the deadline.
57
57
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
58
58
  loop do
59
59
  reaped, status = Process.waitpid2(pid, Process::WNOHANG)
60
60
  return decode(status) if reaped
61
61
 
62
62
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
63
- Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
64
- Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
63
+ begin
64
+ Process.kill(:KILL, -pid)
65
+ rescue Errno::ESRCH, Errno::EPERM
66
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
67
+ end
68
+ begin
69
+ _reaped, status = Process.waitpid2(pid)
70
+ # Child may have finished cleanly between WNOHANG and kill; honor it.
71
+ return decode(status) if status && status.exited? && !status.signaled?
72
+ rescue Errno::ECHILD
73
+ # already reaped
74
+ end
65
75
  return Result.timeout
66
76
  end
67
77
  sleep 0.005
@@ -14,17 +14,19 @@ module Mutineer
14
14
  # @return [void]
15
15
  def visit_statements_node(node)
16
16
  stmts = node.body
17
- return if stmts.length < 2
18
-
19
- stmts[0...-1].each do |stmt|
20
- loc = stmt.location
21
- @mutations << Mutation.new(
22
- start_offset: loc.start_offset,
23
- end_offset: loc.end_offset,
24
- replacement: "nil",
25
- operator: :statement_removal
26
- )
17
+ if stmts.length >= 2
18
+ stmts[0...-1].each do |stmt|
19
+ loc = stmt.location
20
+ @mutations << Mutation.new(
21
+ start_offset: loc.start_offset,
22
+ end_offset: loc.end_offset,
23
+ replacement: "nil",
24
+ operator: :statement_removal
25
+ )
26
+ end
27
27
  end
28
+ # Nested statement lists (if/begin/while bodies) are separate StatementsNodes.
29
+ super
28
30
  end
29
31
  end
30
32
  end
@@ -17,10 +17,9 @@ module Mutineer
17
17
  # fixtures then repopulate that isolated database per test.
18
18
  #
19
19
  # Scope: this pass ships the **SQLite** adapter (per-worker file, hermetic,
20
- # spike-proven). Postgres per-worker provisioning (`CREATE DATABASE <db>-<worker>`
21
- # the measured corruption case) extends {worker_db_config}/{provision} at the marked
22
- # seam in U10; until then a non-SQLite config raises a clear NotImplementedError
23
- # rather than silently mis-routing.
20
+ # spike-proven). Postgres per-worker DBs (`CREATE DATABASE <db>-<worker>`) are not
21
+ # implemented yet; a non-SQLite config raises a clear NotImplementedError rather
22
+ # than silently mis-routing.
24
23
  #
25
24
  # Honest limit (KTD-5): routing failures surface as `error` via {verify_connection!}.
26
25
  # Re-raising an AR error that fires *inside a test body* past Minitest (so an in-test
@@ -63,7 +62,7 @@ module Mutineer
63
62
  # What's gated is runtime PROVISIONING: SQLite files are created on connect,
64
63
  # but Postgres needs an explicit `CREATE DATABASE` per worker (U10). Until that
65
64
  # lands, refuse non-SQLite loudly rather than route to a database that doesn't
66
- # exist. To finish U10: implement provision() for PG and drop this guard.
65
+ # exist until Postgres worker creation is implemented.
67
66
  unless adapter.start_with?("sqlite")
68
67
  raise NotImplementedError,
69
68
  "worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
@@ -112,31 +111,8 @@ module Mutineer
112
111
  verify_connection!
113
112
  end
114
113
 
115
- # Explicit, parent-side provisioning: create + schema-load every worker database
116
- # up front, then restore the app's default connection. This is the EXPLICIT
117
- # provisioning entry point (V2 — never silent auto-create mid-audit) and the seam
118
- # U10 extends for Postgres (`CREATE DATABASE` per worker). For SQLite the files are
119
- # created on connect and the schema load makes them ready; idempotent to re-run.
120
- #
121
- # @param worker_count [Integer] number of worker databases to provision.
122
- # @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
123
- # @return [void]
124
- def self.provision(worker_count, schema_path)
125
- return unless available?
126
-
127
- original = ActiveRecord::Base.connection_db_config
128
- (0...worker_count).each do |worker|
129
- ActiveRecord::Base.establish_connection(worker_db_config(worker))
130
- load_schema(schema_path) if schema_path
131
- end
132
- ensure
133
- ActiveRecord::Base.establish_connection(original) if original
134
- end
135
-
136
- # Load a Rails `schema.rb` into the current connection with its output silenced,
137
- # so a parent-side {provision} call can never spill schema chatter onto the daemon
138
- # IPC pipe. (In a fork the child's stdout is already `File::NULL`; this guards the
139
- # parent path too.)
114
+ # Load a Rails `schema.rb` into the current connection with output silenced
115
+ # (fork child stdout is already File::NULL; this is belt-and-braces).
140
116
  #
141
117
  # @param schema_path [String] absolute path to `db/schema.rb`.
142
118
  # @return [void]
@@ -69,12 +69,21 @@ module Mutineer
69
69
  verdict(out, threshold) if threshold && threshold.positive?
70
70
  end
71
71
 
72
- # 0 pass / 1 below threshold. Usage errors (exit 2) are the CLI's job.
72
+ # 0 pass / 1 below threshold or untestable-with-errors. Usage errors (exit 2)
73
+ # are the CLI's job. When the score is nil (nothing killed or survived), pure
74
+ # no_coverage / all-ignored / empty still skip the gate; if any mutant was
75
+ # errored, timed out, or uncapturable, fail the gate so a broken harness
76
+ # cannot green CI under --threshold.
73
77
  def exit_code(threshold:)
74
78
  return 0 if threshold.nil? || threshold <= 0
75
79
 
76
80
  score = @agg.mutation_score
77
- return 0 if score.nil? # no testable mutants — gate skipped (warning already emitted)
81
+ if score.nil?
82
+ broken = @agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
83
+ return 1 if @agg.total.positive? && broken.positive?
84
+
85
+ return 0 # pure no_coverage / ignored / empty — gate skipped
86
+ end
78
87
 
79
88
  score >= threshold ? 0 : 1
80
89
  end
@@ -380,12 +389,38 @@ module Mutineer
380
389
  "#{@agg.ignored_count} ignored excluded"
381
390
  if score.nil?
382
391
  out.puts "Mutation score: N/A (no covered mutants)"
383
- err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
392
+ if broken_nil_score?
393
+ err.puts "[mutineer] no covered mutations (#{broken_nil_score_detail}); " \
394
+ "threshold gate fails under a positive --threshold (broken harness)."
395
+ else
396
+ err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
397
+ end
384
398
  else
385
399
  out.puts "Mutation score: #{score}% (killed / (killed + survived); #{excluded})"
386
400
  end
387
401
  end
388
402
 
403
+ # True when nil score is due to errors/timeouts/uncapturable (gate must fail).
404
+ #
405
+ # @api private
406
+ # @return [Boolean]
407
+ def broken_nil_score?
408
+ @agg.total.positive? &&
409
+ (@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count).positive?
410
+ end
411
+
412
+ # Human-readable counts for a broken nil-score run.
413
+ #
414
+ # @api private
415
+ # @return [String]
416
+ def broken_nil_score_detail
417
+ parts = []
418
+ parts << "#{@agg.errored_count} errored" if @agg.errored_count.positive?
419
+ parts << "#{@agg.timeout_count} timeout" if @agg.timeout_count.positive?
420
+ parts << "#{@agg.uncapturable_count} uncapturable" if @agg.uncapturable_count.positive?
421
+ parts.join(", ")
422
+ end
423
+
389
424
  # #11: one line per source after the global summary, so a multi-source run
390
425
  # shows which file is weak. Omitted for a single-source run — the global
391
426
  # summary already says everything (ponytail: no redundant one-line block).
@@ -470,7 +505,13 @@ module Mutineer
470
505
  # @return [void]
471
506
  def verdict(out, threshold)
472
507
  score = @agg.mutation_score
473
- return if score.nil?
508
+ if score.nil?
509
+ if broken_nil_score?
510
+ out.puts "FAILED: no covered mutants (#{broken_nil_score_detail}); " \
511
+ "threshold #{threshold}% cannot pass with a broken harness"
512
+ end
513
+ return
514
+ end
474
515
 
475
516
  if score >= threshold
476
517
  out.puts "PASSED: #{score}% >= threshold #{threshold}%"
@@ -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,13 +231,12 @@ 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)
@@ -243,10 +244,9 @@ module Mutineer
243
244
  jobs = filter_since(jobs, source_map, config) if config.since
244
245
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
245
246
 
246
- # #26/U7: build the coverage map once (app-side, via a short-lived daemon) so
247
- # each mutant runs ONLY its covering tests and a mutant on an uncovered line is
248
- # no_coverage. nil when the build fails — the runners fall back to the full
249
- # --test set (no narrowing) rather than mis-scoring everything as no_coverage.
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
250
  coverage_map = daemon_coverage_map(config, abs_tests)
251
251
 
252
252
  # #26/U6: worker count = resolved --jobs, capped at the job count (no idle
@@ -270,24 +270,16 @@ module Mutineer
270
270
  [AggregateResult.new(results + ignored_results), source_map]
271
271
  end
272
272
 
273
- # #26/U7: build the coverage map via a short-lived daemon (boots the app once,
274
- # captures per-test coverage app-side, ships the map back). Returns a query-only
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
275
  # CoverageMap, or nil when the build fails / returns empty — callers then run the
276
- # full --test set (no narrowing) rather than mis-scoring. The dedicated daemon
277
- # costs one extra boot; correctness over that boot (ponytail — the map is reused
278
- # for every mutant).
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
279
  #
280
280
  # @param config [Mutineer::Config] the run config.
281
281
  # @param abs_tests [Array<String>] absolute --test paths.
282
282
  # @return [Mutineer::CoverageMap, nil]
283
- #
284
- # ponytail: the coverage-build IPC read is unbounded (no wall-clock) — a --test
285
- # file that infinite-loops during capture wedges this call, mirroring the existing
286
- # in-process build_via_fork limitation. A capture timeout is a follow-up. When the
287
- # map comes back empty (all captures failed), this returns nil and the run falls
288
- # back to the FULL --test set per mutant — so on a total capture failure the daemon
289
- # score is an upper bound (more testing), diverging from the in-process no_coverage
290
- # scoring for that degenerate case only; a normal (nonempty) map scores identically.
291
283
  def self.daemon_coverage_map(config, abs_tests)
292
284
  client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests, coverage: true),
293
285
  app_root: config.project_root).start
@@ -296,14 +288,30 @@ module Mutineer
296
288
  ensure
297
289
  client.quit
298
290
  end
299
- return nil unless data && !(data["map"] || {}).empty?
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
300
296
 
301
297
  CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
302
298
  project_root: config.project_root)
303
- rescue DaemonBootError
299
+ rescue DaemonBootError => e
300
+ warn_daemon_coverage_fallback("#{e.class}: #{e.message}")
304
301
  nil
305
302
  end
306
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
+
307
315
  # Serial daemon path: one daemon (worker 0), one mutant at a time. Honors
308
316
  # --fail-fast (#21: stop at the first survivor).
309
317
  #
@@ -324,24 +332,16 @@ module Mutineer
324
332
  results
325
333
  end
326
334
 
327
- # Parallel daemon path (#26/U6): N daemon handles, each pinned to its own worker
328
- # slot (→ its own DB, so concurrent workers can't clobber each other's fixtures).
329
- # A shared queue of job indices feeds N tool-side threads; each thread blocks on
330
- # IPC (GVL released), so the N daemons run genuinely concurrently. Results are
331
- # placed by input index and compacted, so the verdict SET is identical to serial
332
- # regardless of finish order (R7). `execute_daemon` never calls this with
333
- # `config.fail_fast` set (forced to the serial path instead) — a wall-clock stop
334
- # here, unlike serial's input-index break, would make the survivor set and score
335
- # non-deterministic. The stop-flag machinery below stays as a defensive no-op for
336
- # any direct caller that bypasses that guard.
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).
337
339
  #
338
340
  # @return [Array<Mutineer::Result>] completed results in input order.
339
341
  def self.run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
340
- results = Array.new(jobs.size)
341
- queue = Queue.new
342
+ results = Array.new(jobs.size)
343
+ queue = Queue.new
342
344
  jobs.each_index { |i| queue << i }
343
- stop = false
344
- stop_mutex = Mutex.new
345
345
 
346
346
  clients = Array.new(worker_count) do
347
347
  DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
@@ -350,15 +350,13 @@ module Mutineer
350
350
 
351
351
  clients.each_with_index.map do |client, worker|
352
352
  Thread.new do
353
- until stop_mutex.synchronize { stop }
353
+ loop do
354
354
  i = begin
355
- queue.pop(true) # non-blocking; ThreadError when drained
355
+ queue.pop(true)
356
356
  rescue ThreadError
357
357
  break
358
358
  end
359
- r = daemon_job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
360
- results[i] = r
361
- stop_mutex.synchronize { stop = true } if config.fail_fast && r.survived?
359
+ results[i] = daemon_job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
362
360
  end
363
361
  ensure
364
362
  client.quit
@@ -433,8 +431,9 @@ module Mutineer
433
431
  [:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
434
432
  end
435
433
 
436
- # Default per-mutant timeout on the daemon path. Generous because 2a runs the full
437
- # `--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.
438
437
  DAEMON_TIMEOUT = 60
439
438
 
440
439
  # The boot config the daemon needs to boot the app once: where to boot, the test
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.11.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.11.0
4
+ version: 0.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren