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.
@@ -20,17 +20,23 @@ module Mutineer
20
20
  #
21
21
  # Protocol (one JSON object per line, both directions):
22
22
  # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
23
- # "load_paths":["test"],"framework":"minitest","rails":true}
23
+ # "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
24
24
  # ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
25
- # run in : {"id":N,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
25
+ # run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
26
26
  # "tests":["test/models/order_test.rb"],"timeout":30}
27
27
  # verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
28
28
  # quit in : {"cmd":"quit"}
29
29
  #
30
+ # Worker isolation (#26/U5): when the app is Rails, each fork is routed to its own
31
+ # database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so concurrent
32
+ # workers can't clobber each other's transactional fixtures. `worker` defaults to 0
33
+ # (serial). SQLite this pass; Postgres provisioning is U10.
34
+ #
30
35
  # Verdict mapping (KTD-5, Phase-2a honest limit): child exit 0=survived (suite
31
- # passed), 1=killed (suite failed), 2=error (child raised AROUND the test — load or
32
- # boot failure); parent-detected timeout. Tagging an in-TEST DB error as `error`
33
- # (vs killed) is a Phase-2b concern (needs the after_fork adapter's re-raise).
36
+ # passed), 1=killed (suite failed), 2=error (child raised AROUND the test — load,
37
+ # boot, or worker-DB routing failure); parent-detected timeout. Tagging an in-TEST DB
38
+ # error (one fired inside a test body, vs at routing time) as `error` rather than
39
+ # `killed` is a U6 concern — only observable under the concurrent gate.
34
40
  module DaemonServer
35
41
  # Poll interval (seconds) for the per-fork deadline wait loop.
36
42
  POLL = 0.02
@@ -67,6 +73,14 @@ module Mutineer
67
73
  end
68
74
  break if req["cmd"] == "quit"
69
75
 
76
+ # #26/U7: build the coverage map app-side and ship it to the tool, which
77
+ # then selects covering tests per mutant. One-shot control message.
78
+ if req["cmd"] == "coverage"
79
+ output.puts(JSON.generate(build_coverage_map))
80
+ output.flush
81
+ next
82
+ end
83
+
70
84
  output.puts(JSON.generate(run_mutant(req)))
71
85
  output.flush
72
86
  end
@@ -77,16 +91,24 @@ module Mutineer
77
91
  # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded and
78
92
  # inherited by every fork. Never requires mutineer.
79
93
  def boot!(cfg)
94
+ @cfg = cfg
80
95
  @framework = cfg.fetch("framework", "minitest")
81
96
  @source_dirs = Array(cfg["source_dirs"]).map { |d| File.expand_path(d) }
82
97
  Dir.chdir(cfg["project_root"]) if cfg["project_root"]
83
98
  ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
84
99
  Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
100
+ # #26/U7: start Coverage BEFORE the app loads, so booted source lines are
101
+ # instrumented — the map build (build_via_fork) forks this booted parent.
102
+ if cfg["coverage"]
103
+ require "coverage"
104
+ Coverage.start(lines: true)
105
+ end
85
106
  # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
86
107
  # source dir BEFORE the app boots — Zeitwerk would otherwise choke on the
87
108
  # tempfile's non-constant name during autoload setup.
88
109
  sweep_temps
89
110
  require File.expand_path(cfg["boot"]) if cfg["boot"]
111
+ setup_worker_db(cfg) if cfg["rails"]
90
112
  rescue Exception => e # rubocop:disable Lint/RescueException
91
113
  # Boot failed (bad boot path, app error) — tell the client and exit so it can
92
114
  # surface a clean error rather than hang on the handshake.
@@ -95,17 +117,68 @@ module Mutineer
95
117
  exit!(1)
96
118
  end
97
119
 
120
+ # Load the per-worker DB adapter app-side (sibling gem file, by relative path so
121
+ # it bypasses the app bundle — like this daemon itself). No-op unless the app has
122
+ # ActiveRecord. Records the adapter + schema path so each fork can route to its
123
+ # own database (#26 isolation, U5). SQLite-only this pass; a non-SQLite config
124
+ # raises in the fork and reads as `error`, never a mis-routed verdict.
125
+ def setup_worker_db(cfg)
126
+ require_relative "rails_worker_db"
127
+ @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
128
+ schema = cfg["schema"] && File.expand_path(cfg["schema"])
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 = {}
132
+ rescue LoadError => e
133
+ @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
134
+ @worker_db = nil
135
+ end
136
+
137
+ # #26/U7: build the coverage map app-side (Coverage was started at boot) and
138
+ # return it as `{map, failed_test_files}` for the tool to select covering tests.
139
+ # Capture forks route to worker 0's DB (isolated, serial). On any failure return
140
+ # an empty map + an error string — the tool then falls back to the full test set
141
+ # rather than mis-scoring everything as no_coverage.
142
+ def build_coverage_map
143
+ require_relative "coverage_map"
144
+ root = @cfg["project_root"] || Dir.pwd
145
+ cmap = CoverageMap.new(
146
+ source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
147
+ load_paths: Array(@cfg["load_paths"]), project_root: root,
148
+ boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
149
+ ).build_via_fork(after_fork: coverage_after_fork)
150
+ { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files }
151
+ rescue Exception => e # rubocop:disable Lint/RescueException
152
+ @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
153
+ { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
154
+ end
155
+
156
+ # Fork-safety hook for coverage capture: route each capture fork to worker 0's
157
+ # isolated DB (captures run serially, so one worker is enough). Nil when the app
158
+ # has no worker-DB adapter (non-Rails) — capture then runs as before.
159
+ def coverage_after_fork
160
+ return nil unless @worker_db
161
+
162
+ schema = @schema_path
163
+ -> { @worker_db.after_fork(0, schema) }
164
+ end
165
+
98
166
  # Fork a child to run one mutant in isolation; decode its exit into a verdict.
99
167
  def run_mutant(req)
100
168
  timeout = req.fetch("timeout", 30)
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
101
172
  pid = fork do
102
- # New process group so a per-fork timeout can SIGKILL the whole subtree
103
- # (carries the Phase-1 pgroup discipline), and silence the child's stdout so
104
- # 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.
105
175
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
106
176
  $stdout.reopen(File::NULL, "w")
107
177
  code =
108
178
  begin
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)
109
182
  apply_payload(req["payload"])
110
183
  run_tests(Array(req["tests"]))
111
184
  rescue Exception => e # rubocop:disable Lint/RescueException
@@ -115,6 +188,10 @@ module Mutineer
115
188
  exit!(code)
116
189
  end
117
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)
118
195
  # A SIGKILLed timeout child skipped its Tempfile unlink — sweep the orphan so
119
196
  # it can't outlive the run or trip Zeitwerk on a later fork.
120
197
  sweep_temps if verdict == "timeout"
@@ -151,7 +228,14 @@ module Mutineer
151
228
  rescue Errno::ESRCH, Errno::EPERM
152
229
  Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
153
230
  end
154
- 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
155
239
  return "timeout"
156
240
  end
157
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
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # #26/#27 Phase 2b (U5) — per-worker database isolation for the daemon path.
5
+ #
6
+ # Loaded APP-SIDE by {DaemonServer} (a sibling gem file, pulled in by absolute path
7
+ # so it bypasses the app bundle — the same trick {DaemonClient} uses to run
8
+ # `daemon_server.rb` under a bundle that has no mutineer). It uses the app's OWN
9
+ # already-booted ActiveRecord and NEVER `require "active_record"` (R10/KTD-8): every
10
+ # method that touches AR first confirms {available?}, so the daemon core stays
11
+ # framework-agnostic and the gem keeps its zero-runtime-dependency promise.
12
+ #
13
+ # Isolation model (KTD-7): each parallel worker gets its OWN database so concurrent
14
+ # forks can't clobber each other's transactional fixtures (the measured #26
15
+ # corruption). {after_fork} runs inside a freshly-forked child and points that
16
+ # child's connection at the worker's database BEFORE any test loads; transactional
17
+ # fixtures then repopulate that isolated database per test.
18
+ #
19
+ # Scope: this pass ships the **SQLite** adapter (per-worker file, hermetic,
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.
23
+ #
24
+ # Honest limit (KTD-5): routing failures surface as `error` via {verify_connection!}.
25
+ # Re-raising an AR error that fires *inside a test body* past Minitest (so an in-test
26
+ # DB failure is `error`, not `killed`) is only observable under concurrent load and
27
+ # is deferred to U6 with the parallel gate — noted, not silently skipped.
28
+ module RailsWorkerDb
29
+ # True when the app has ActiveRecord loaded — the only condition under which any
30
+ # other method here may touch AR. Never triggers an autoload/require of AR itself.
31
+ #
32
+ # @return [Boolean]
33
+ def self.available?
34
+ defined?(ActiveRecord::Base) ? true : false
35
+ end
36
+
37
+ # Derive a per-worker database path from a base path by inserting `-<worker>`
38
+ # before the extension. Pure string transform (no AR) so it is unit-testable in
39
+ # the zero-dep suite. `storage/test.sqlite3`, worker 1 -> `storage/test-1.sqlite3`.
40
+ #
41
+ # @param database [String] the base database path.
42
+ # @param worker [Integer] the worker slot index (0..N-1).
43
+ # @return [String] the per-worker database path.
44
+ def self.worker_database_path(database, worker)
45
+ ext = File.extname(database)
46
+ "#{database.delete_suffix(ext)}-#{worker}#{ext}"
47
+ end
48
+
49
+ # Build the AR connection config for one worker by copying the app's current
50
+ # (default test) config and swapping in the per-worker database path. SQLite only
51
+ # this pass — a non-SQLite adapter raises so the SQLite-first scope fails loud
52
+ # instead of mis-routing (Postgres is U10).
53
+ #
54
+ # @param worker [Integer] the worker slot index.
55
+ # @return [Hash] a symbol-keyed AR configuration hash for the worker database.
56
+ # @raise [NotImplementedError] when the app's database is non-SQLite or in-memory.
57
+ def self.worker_db_config(worker)
58
+ hash = ActiveRecord::Base.connection_db_config.configuration_hash
59
+ adapter = hash[:adapter].to_s
60
+ # U10 seam: the config-SHAPING below (per_worker_config) is already
61
+ # adapter-general — it derives correct SQLite *and* Postgres worker-DB names.
62
+ # What's gated is runtime PROVISIONING: SQLite files are created on connect,
63
+ # but Postgres needs an explicit `CREATE DATABASE` per worker (U10). Until that
64
+ # lands, refuse non-SQLite loudly rather than route to a database that doesn't
65
+ # exist until Postgres worker creation is implemented.
66
+ unless adapter.start_with?("sqlite")
67
+ raise NotImplementedError,
68
+ "worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
69
+ "Postgres per-worker provisioning is U10 (#26/#35) — use a SQLite test DB, or drop --jobs."
70
+ end
71
+
72
+ per_worker_config(hash, worker)
73
+ end
74
+
75
+ # Pure config-shaping (no AR): given a connection config hash, return the
76
+ # per-worker variant with its database swapped to the worker's own name.
77
+ # Adapter-general — SQLite (`storage/test.sqlite3` → `storage/test-<w>.sqlite3`)
78
+ # and Postgres (`myapp_test` → `myapp_test-<w>`, Rails `parallelize` naming) both
79
+ # fall out of {worker_database_path}. Extracted + unit-tested so the Postgres
80
+ # SHAPE is proven ready for U10 without a live database.
81
+ #
82
+ # @param config_hash [Hash] a connection config hash (symbol or string keys).
83
+ # @param worker [Integer] the worker slot index.
84
+ # @return [Hash] the per-worker config (symbol keys), database swapped.
85
+ # @raise [NotImplementedError] for an in-memory or empty database (no per-worker split).
86
+ def self.per_worker_config(config_hash, worker)
87
+ hash = config_hash.transform_keys(&:to_sym)
88
+ database = hash[:database].to_s
89
+ if database.empty? || database == ":memory:"
90
+ raise NotImplementedError,
91
+ "worker-DB isolation needs a file/name-backed database (got #{database.inspect})."
92
+ end
93
+
94
+ hash.merge(database: worker_database_path(database, worker))
95
+ end
96
+
97
+ # Child-side (after fork): route this process's ActiveRecord at the worker's own
98
+ # database and confirm it is reachable, so a routing failure reads as `error`
99
+ # (via the daemon's child rescue) rather than a false verdict. Loads the schema
100
+ # into the worker database when a schema path is given (idempotent — schema.rb
101
+ # runs with `force: true`), covering a fresh worker file.
102
+ #
103
+ # @param worker [Integer] the worker slot index.
104
+ # @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
105
+ # @return [void]
106
+ def self.after_fork(worker, schema_path = nil)
107
+ return unless available?
108
+
109
+ ActiveRecord::Base.establish_connection(worker_db_config(worker))
110
+ load_schema(schema_path) if schema_path
111
+ verify_connection!
112
+ end
113
+
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).
116
+ #
117
+ # @param schema_path [String] absolute path to `db/schema.rb`.
118
+ # @return [void]
119
+ def self.load_schema(schema_path)
120
+ ActiveRecord::Migration.verbose = false if defined?(ActiveRecord::Migration)
121
+ original = $stdout
122
+ $stdout = File.open(File::NULL, "w")
123
+ load schema_path
124
+ ensure
125
+ $stdout.close unless $stdout.equal?(original)
126
+ $stdout = original
127
+ end
128
+
129
+ # Force a round-trip to the freshly-routed connection so a broken route fails HERE
130
+ # (→ `error`) instead of later masquerading as a test failure (→ false `killed`).
131
+ #
132
+ # @return [void]
133
+ def self.verify_connection!
134
+ ActiveRecord::Base.connection.execute("SELECT 1")
135
+ end
136
+ end
137
+ end
@@ -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}%"