mutineer 1.3.0 → 1.5.0

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +317 -0
  3. data/README.md +200 -28
  4. data/lib/mutineer/changed_lines.rb +57 -2
  5. data/lib/mutineer/cli.rb +142 -92
  6. data/lib/mutineer/config.rb +215 -56
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutator_registry.rb +12 -3
  21. data/lib/mutineer/mutators/array_literal.rb +0 -7
  22. data/lib/mutineer/mutators/base.rb +63 -4
  23. data/lib/mutineer/mutators/chain_link.rb +0 -7
  24. data/lib/mutineer/mutators/collection_method.rb +1 -1
  25. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  26. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  27. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  28. data/lib/mutineer/mutators/return_nil.rb +5 -11
  29. data/lib/mutineer/mutators/string_literal.rb +1 -1
  30. data/lib/mutineer/pairing.rb +222 -15
  31. data/lib/mutineer/parser.rb +12 -3
  32. data/lib/mutineer/project.rb +7 -0
  33. data/lib/mutineer/rails_worker_db.rb +1 -1
  34. data/lib/mutineer/reporter.rb +288 -52
  35. data/lib/mutineer/result.rb +37 -1
  36. data/lib/mutineer/runner.rb +133 -16
  37. data/lib/mutineer/statement_lines.rb +143 -0
  38. data/lib/mutineer/test_runners/minitest.rb +4 -2
  39. data/lib/mutineer/test_runners/rspec.rb +243 -3
  40. data/lib/mutineer/test_runners.rb +5 -3
  41. data/lib/mutineer/version.rb +1 -1
  42. data/lib/mutineer/worker_pool.rb +4 -2
  43. data/lib/mutineer.rb +3 -0
  44. metadata +10 -3
@@ -25,9 +25,24 @@ module Mutineer
25
25
  # workflow, so one bad mutant never fails the gate on its own, at any size.
26
26
  BROKEN_FLOOR = 1
27
27
 
28
- def initialize(aggregate, source_map)
28
+ # The JSON report's `schema_version` (see docs/json-schema.md).
29
+ SCHEMA_VERSION = "1.6"
30
+
31
+ # The warning both matrix renderers give under the redundant tests.
32
+ MATRIX_REDUNDANT_NOTE = "Delete redundant tests one at a time: two of them can be the only killers of one mutant."
33
+
34
+ # How many blind or redundant tests the human report lists before it points
35
+ # to `--format json` for the rest.
36
+ MATRIX_LIST_LIMIT = 20
37
+
38
+ # @param aggregate [Mutineer::AggregateResult] the run's results.
39
+ # @param source_map [Hash{String => String}] source file path => source text.
40
+ # @param matrix [Mutineer::KillMatrix, nil] the kill matrix of a `--matrix`
41
+ # run; nil leaves every format exactly as it is without the flag.
42
+ def initialize(aggregate, source_map, matrix: nil)
29
43
  @agg = aggregate
30
44
  @source_map = source_map
45
+ @matrix = matrix
31
46
  end
32
47
 
33
48
  # Single entry point. Branches on `format` ("human" | "json" | "html") and
@@ -96,6 +111,7 @@ module Mutineer
96
111
  per_source(out)
97
112
 
98
113
  survivors(out)
114
+ matrix_section(out) if @matrix
99
115
  verdict(out, threshold) if threshold && threshold.positive?
100
116
  end
101
117
 
@@ -146,7 +162,7 @@ module Mutineer
146
162
  score = @agg.mutation_score
147
163
 
148
164
  doc = {
149
- schema_version: "1.4",
165
+ schema_version: SCHEMA_VERSION,
150
166
  summary: {
151
167
  total: @agg.total, killed: killed, survived: survived,
152
168
  no_coverage: @agg.no_coverage_count,
@@ -172,17 +188,18 @@ module Mutineer
172
188
  },
173
189
  survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
174
190
  .sort_by { |h| [h[:file], h[:line], h[:operator]] },
175
- no_coverage: @agg.results.select(&:no_coverage?).map { |r| no_coverage_json(r) }
176
- .sort_by { |h| [h[:file], h[:line]] },
191
+ # id extends the key to a total order: several mutants share a line.
192
+ no_coverage: @agg.results.select(&:no_coverage?).map { |r| mutant_json(r) }
193
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
177
194
  # Same shape as no_coverage; additive key.
178
- uncapturable: @agg.results.select(&:uncapturable?).map { |r| no_coverage_json(r) }
179
- .sort_by { |h| [h[:file], h[:line]] },
195
+ uncapturable: @agg.results.select(&:uncapturable?).map { |r| mutant_json(r) }
196
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
180
197
  # Every mutant that was attempted and produced no verdict, whatever the
181
198
  # reason — the set the --threshold completeness gate counts. Named for the
182
199
  # condition rather than one status, because summary.errored means :error
183
200
  # alone and a key that reconciled with neither would be worse. `details`
184
201
  # carries the cause where there is one. Uncapturable mutants also appear in
185
- # uncapturable[]; that key keeps its lean shape for existing consumers.
202
+ # uncapturable[], which stays for consumers that already read it.
186
203
  # to_s/to_i because a pre-fork failure has no subject, so its file and line
187
204
  # are null and would not compare against a real entry. id and status extend
188
205
  # the key to a total order: these entries collide on (file, line) far more
@@ -195,13 +212,15 @@ module Mutineer
195
212
  # Equivalent mutants the user suppressed: emitted with their stable id so
196
213
  # the user can audit what is silenced (and copy ids for survivors they
197
214
  # want to add). Excluded from the score; never in `survivors`.
198
- ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
199
- .sort_by { |h| [h[:file], h[:line], h[:operator]] },
215
+ ignored: @agg.results.select(&:ignored?).map { |r| mutant_json(r) }
216
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
200
217
  # Per-source breakdown (additive; baseline consumes it). Sorted by file so
201
218
  # output is byte-stable. Reuses AggregateResult via by_source.
202
219
  per_source: @agg.by_source.map { |file, agg| per_source_json(file, agg) }
203
220
  .sort_by { |h| h[:file] }
204
221
  }
222
+ # Additive (1.5): the kill matrix, present only with --matrix.
223
+ doc[:matrix] = matrix_json if @matrix
205
224
  # Additive baseline-delta block, present only with --baseline. Existing
206
225
  # consumers ignore the extra key; it does not move schema_version on its own.
207
226
  doc[:baseline] = baseline_json(baseline) if baseline
@@ -257,6 +276,7 @@ module Mutineer
257
276
  #{summary_html}
258
277
  #{per_source_html(per_source)}
259
278
  #{survivors_html(survivors)}
279
+ #{matrix_html}
260
280
  </body>
261
281
  </html>
262
282
  HTML
@@ -269,7 +289,7 @@ module Mutineer
269
289
  "survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
270
290
  "uncapturable" => @agg.uncapturable_count, "ignored" => @agg.ignored_count,
271
291
  "skipped" => @agg.skipped_invalid_count,
272
- "errored" => @agg.errored_count + @agg.timeout_count
292
+ "errored" => @agg.errored_count, "timeout" => @agg.timeout_count
273
293
  }
274
294
  spans = counts.map { |k, v| "<span><strong>#{v}</strong> #{esc(k)}</span>" }.join("\n ")
275
295
  "<div class=\"counts\">\n #{spans}\n</div>"
@@ -316,11 +336,191 @@ module Mutineer
316
336
  "<h2>Surviving Mutants</h2>\n#{cards}"
317
337
  end
318
338
 
339
+ # The kill-matrix section of the HTML report: the counts, then the blind and
340
+ # redundant tests. Empty without --matrix.
341
+ #
342
+ # @api private
343
+ # @return [String] HTML.
344
+ def matrix_html
345
+ return "" unless @matrix
346
+
347
+ lists = { "Blind tests" => @matrix.blind, "Redundant tests" => @matrix.redundant }.map do |title, tests|
348
+ items = tests.map { |test| "<li><span class=\"id\">#{esc(test[0])}</span> #{esc(test_label(test))}</li>" }
349
+ body = items.empty? ? "<p>None.</p>" : "<ul>\n#{items.join("\n")}\n</ul>"
350
+ "<h3>#{esc(title)} (#{tests.size})</h3>\n#{body}"
351
+ end
352
+ note = @matrix.complete? ? "" : "\n<p>#{esc(matrix_incomplete_note)}</p>\n#{matrix_incomplete_html}"
353
+ delete = @matrix.redundant.empty? ? "" : "\n<p>#{esc(MATRIX_REDUNDANT_NOTE)}</p>"
354
+ "<h2>Kill matrix</h2>\n<p>#{esc(matrix_counts_line)}</p>#{note}\n#{lists.join("\n")}#{delete}"
355
+ end
356
+
357
+ # Every incomplete row of the HTML kill-matrix section.
358
+ #
359
+ # @api private
360
+ # @return [String] HTML.
361
+ def matrix_incomplete_html
362
+ items = @matrix.incomplete_rows.map { |r| "<li>#{esc(matrix_row_label(r))}</li>" }
363
+ "<h3>Incomplete rows (#{items.size})</h3>\n<ul>\n#{items.join("\n")}\n</ul>"
364
+ end
365
+
366
+ # The JSON `matrix` block: every test with its kill count, one row per
367
+ # mutant that ran (its killers as indexes into `tests`), and the blind and
368
+ # redundant tests. Rows sort like survivors, then by id, so output is
369
+ # byte-stable regardless of --jobs.
370
+ #
371
+ # @api private
372
+ # @return [Hash] the matrix JSON object.
373
+ def matrix_json
374
+ tests = @matrix.tests
375
+ index = tests.each_with_index.to_h
376
+ {
377
+ complete: @matrix.complete?,
378
+ tests: tests.map { |test| test_json(test).merge(kills: @matrix.kill_count(test)) },
379
+ mutants: @matrix.rows.map { |r| matrix_row_json(r, index) }
380
+ .sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:operator].to_s, h[:id].to_s] },
381
+ blind: @matrix.blind.map { |test| test_json(test) },
382
+ redundant: @matrix.redundant.map { |test| test_json(test) }
383
+ }
384
+ end
385
+
386
+ # One test as JSON: its file, display name and id.
387
+ #
388
+ # @api private
389
+ # @param test [Array(String, String, String)] a `[file, name, id]` test.
390
+ # @return [Hash]
391
+ def test_json(test)
392
+ file, name, id = test
393
+ { file: file, name: name, id: id }
394
+ end
395
+
396
+ # A test's name for people: the display name, plus the id when it differs
397
+ # (an RSpec example id, which tells apart examples that share a name and
398
+ # runs exactly that example with `rspec`).
399
+ #
400
+ # @api private
401
+ # @param test [Array(String, String, String)] a `[file, name, id]` test.
402
+ # @return [String]
403
+ def test_label(test)
404
+ _file, name, id = test
405
+ id == name ? name : "#{name} (#{id})"
406
+ end
407
+
408
+ # One mutant's row under `matrix.mutants`.
409
+ #
410
+ # @api private
411
+ # @param result [Mutineer::Result] a result with a {Kills} row.
412
+ # @param index [Hash{Array => Integer}] test => its index in `matrix.tests`.
413
+ # @return [Hash] the row JSON object.
414
+ def matrix_row_json(result, index)
415
+ file = result.subject&.file
416
+ kills = result.kills
417
+ {
418
+ subject: result.subject&.qualified_name, file: file, line: result_line(result),
419
+ operator: result.mutation&.operator&.to_s, id: result.id, status: result.status.to_s,
420
+ killed_by: kills.killed_by.map { |test| index.fetch(test) }.sort,
421
+ ran: kills.ran.size, complete: kills.complete
422
+ }
423
+ end
424
+
425
+ # The kill-matrix section of the human report.
426
+ #
427
+ # @api private
428
+ # @param out [IO] output stream.
429
+ # @return [void]
430
+ def matrix_section(out)
431
+ out.puts
432
+ out.puts "Kill matrix"
433
+ out.puts "-----------"
434
+ out.puts matrix_counts_line
435
+ matrix_incomplete_section(out) unless @matrix.complete?
436
+ matrix_list(out, "Blind tests (ran, killed no mutant)", @matrix.blind)
437
+ matrix_list(out, "Redundant tests (each mutant they kill has another killer)", @matrix.redundant)
438
+ return if @matrix.redundant.empty?
439
+
440
+ out.puts MATRIX_REDUNDANT_NOTE
441
+ end
442
+
443
+ # The human report's warning about incomplete rows, with the first
444
+ # {MATRIX_LIST_LIMIT} of them named.
445
+ #
446
+ # @api private
447
+ # @param out [IO] output stream.
448
+ # @return [void]
449
+ def matrix_incomplete_section(out)
450
+ rows = @matrix.incomplete_rows
451
+ out.puts matrix_incomplete_note
452
+ rows.first(MATRIX_LIST_LIMIT).each { |r| out.puts " #{printable(matrix_row_label(r))}" }
453
+ rest = rows.size - MATRIX_LIST_LIMIT
454
+ out.puts " and #{rest} more" if rest.positive?
455
+ out.puts "The HTML and JSON reports list every incomplete row (JSON: matrix.mutants with complete: false)."
456
+ end
457
+
458
+ # A row's label: subject, place, operator, status and id.
459
+ #
460
+ # @api private
461
+ # @param result [Mutineer::Result] a result with a {Kills} row.
462
+ # @return [String]
463
+ def matrix_row_label(result)
464
+ place = [result.subject&.file, result_line(result)].compact.join(":")
465
+ [result.subject&.qualified_name, ("(#{place})" unless place.empty?), result.mutation&.operator,
466
+ result.status, result.id].compact.join(" ")
467
+ end
468
+
469
+ # The 1-based line of a result's mutation, or nil without one (a pre-fork
470
+ # failure has no subject or mutation).
471
+ #
472
+ # @api private
473
+ # @param result [Mutineer::Result] any result.
474
+ # @return [Integer, nil]
475
+ def result_line(result)
476
+ file = result.subject&.file
477
+ return unless result.mutation && file
478
+
479
+ source = @source_map[file] || File.read(file)
480
+ source.byteslice(0, result.mutation.start_offset).count("\n") + 1
481
+ end
482
+
483
+ # One titled list of tests in the human kill-matrix section, cut at
484
+ # {MATRIX_LIST_LIMIT}; the JSON report has the whole list.
485
+ #
486
+ # @api private
487
+ # @param out [IO] output stream.
488
+ # @param title [String] the list's title.
489
+ # @param tests [Array<Array(String, String, String)>] `[file, name, id]` tests.
490
+ # @return [void]
491
+ def matrix_list(out, title, tests)
492
+ out.puts
493
+ out.puts "#{title}: #{tests.size}"
494
+ tests.first(MATRIX_LIST_LIMIT).each { |test| out.puts " #{printable(test[0])} #{printable(test_label(test))}" }
495
+ rest = tests.size - MATRIX_LIST_LIMIT
496
+ out.puts " and #{rest} more; see --format json" if rest.positive?
497
+ end
498
+
499
+ # The counts sentence both matrix renderers open with.
500
+ #
501
+ # @api private
502
+ # @return [String]
503
+ def matrix_counts_line
504
+ "#{@matrix.tests.size} tests ran against #{@matrix.rows.size} mutants " \
505
+ "(every test in each mutant's covering files): " \
506
+ "#{@matrix.blind.size} blind, #{@matrix.redundant.size} redundant."
507
+ end
508
+
509
+ # The warning both matrix renderers give when a row is incomplete.
510
+ #
511
+ # @api private
512
+ # @return [String]
513
+ def matrix_incomplete_note
514
+ "#{@matrix.incomplete_rows.size} mutants could not be verified as complete (a timeout, an error, " \
515
+ "an exit, an interrupt or a broken stream), so a blind test may have killed one of them. " \
516
+ "Do not delete a blind test until these rows are complete:"
517
+ end
518
+
319
519
  # HTML-escapes any text destined for the document (stdlib CGI).
320
520
  def esc(text) = CGI.escapeHTML(text.to_s)
321
521
 
322
522
  # The same delta facts the human report prints, for dashboards. new_survivors
323
- # reuse the ignored_json shape (subject/file/line/operator/token/id) and sort
523
+ # reuse the mutant_json shape (subject/file/line/operator/token/id) and sort
324
524
  # byte-stably so output does not depend on --jobs finish order.
325
525
  def baseline_json(delta)
326
526
  {
@@ -332,8 +532,8 @@ module Mutineer
332
532
  # a diff-scoped side), so a consumer knows not to render the two scores
333
533
  # as a comparison.
334
534
  score_comparable: delta.score_comparable,
335
- new_survivors: delta.new_survivors.map { |r| ignored_json(r) }
336
- .sort_by { |h| [h[:file], h[:line], h[:operator]] },
535
+ new_survivors: delta.new_survivors.map { |r| mutant_json(r) }
536
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
337
537
  fixed_survivors: delta.fixed_survivors.map do |h|
338
538
  { subject: h["subject"], file: h["file"], line: h["line"],
339
539
  operator: h["operator"], id: h["id"] }
@@ -365,8 +565,11 @@ module Mutineer
365
565
  file = result.subject.file
366
566
  source = @source_map[file] || File.read(file)
367
567
  start_line, original_block, mutated_block, token = diff_for(m, source)
368
- minus = original_block.each_line.map { |l| "-#{l.chomp}" }.join("\n")
369
- plus = mutated_block.each_line.map { |l| "+#{l.chomp}" }.join("\n")
568
+ # A block at the end of the file has no newline after it; elsewhere the
569
+ # file's next byte is one. Each side's own text decides its marker.
570
+ at_eof = source.byteindex("\n", m.end_offset).nil?
571
+ old_lines = side_lines(original_block, at_eof)
572
+ new_lines = side_lines(mutated_block, at_eof)
370
573
  {
371
574
  subject: result.subject.qualified_name,
372
575
  file: file,
@@ -376,12 +579,52 @@ module Mutineer
376
579
  # user can paste it straight into .mutineer.yml `ignore:`.
377
580
  id: result.id,
378
581
  token: token,
379
- diff: "--- a/#{file}\n+++ b/#{file}\n@@ -#{start_line} +#{start_line} @@\n#{minus}\n#{plus}\n"
582
+ diff: "--- a/#{file}\n+++ b/#{file}\n" \
583
+ "@@ -#{hunk_range(start_line, old_lines)} +#{hunk_range(start_line, new_lines)} @@\n" \
584
+ "#{diff_body("-", old_lines)}#{diff_body("+", new_lines)}"
380
585
  }
381
586
  end
382
587
 
383
- # An entry under the JSON `ignored:` key: what the user already suppressed.
384
- def ignored_json(result)
588
+ # One side of the diff as the file holds it: the {#diff_for} block plus the
589
+ # newline that follows it, unless the block ends the file. Each line keeps
590
+ # its own ending, so a CRLF line keeps its "\r" and a last line with no
591
+ # newline is told apart from one with a newline.
592
+ #
593
+ # @api private
594
+ # @param block [String] the block's text.
595
+ # @param at_eof [Boolean] no newline follows the block in the file.
596
+ # @return [Array<String>] its lines, with their endings.
597
+ def side_lines(block, at_eof)
598
+ (at_eof ? block : "#{block}\n").lines
599
+ end
600
+
601
+ # The `-` or `+` lines of one side. A line with no newline is the file's
602
+ # last, so it gets the unified diff's no-newline marker (#106).
603
+ #
604
+ # @api private
605
+ # @param sign [String] "-" or "+".
606
+ # @param lines [Array<String>] the side's lines, with their endings.
607
+ # @return [String]
608
+ def diff_body(sign, lines)
609
+ lines.map { |l| l.end_with?("\n") ? "#{sign}#{l}" : "#{sign}#{l}\n\\n" }.join
610
+ end
611
+
612
+ # One side of a unified diff hunk header: `start` for one line, else
613
+ # `start,count`; an empty side is `start-1,0` (#106).
614
+ #
615
+ # @api private
616
+ # @param start_line [Integer] 1-based first line of the block.
617
+ # @param lines [Array<String>] the block's lines.
618
+ # @return [String] the range, e.g. "3", "3,2" or "2,0".
619
+ def hunk_range(start_line, lines)
620
+ return "#{start_line - 1},0" if lines.empty? # an empty side names the line before it
621
+
622
+ lines.size == 1 ? start_line.to_s : "#{start_line},#{lines.size}"
623
+ end
624
+
625
+ # The fields that name one mutant, for the lists that point at mutants:
626
+ # `no_coverage`, `uncapturable`, `ignored` and `baseline.new_survivors`.
627
+ def mutant_json(result)
385
628
  m = result.mutation
386
629
  file = result.subject.file
387
630
  source = @source_map[file] || File.read(file)
@@ -415,22 +658,6 @@ module Mutineer
415
658
  [start_line, original_block, mutated_block, token]
416
659
  end
417
660
 
418
- # Builds no-coverage JSON.
419
- #
420
- # @api private
421
- # @param result [Mutineer::Result] result object.
422
- # @return [Hash] no-coverage JSON object.
423
- def no_coverage_json(result)
424
- m = result.mutation
425
- file = result.subject.file
426
- source = @source_map[file] || File.read(file)
427
- {
428
- subject: result.subject.qualified_name,
429
- file: file,
430
- line: source.byteslice(0, m.start_offset).count("\n") + 1
431
- }
432
- end
433
-
434
661
  # An entry under the JSON `no_verdict:` key: an attempted mutant with no verdict.
435
662
  # A pre-fork failure has no subject or mutation attached, so those degrade to
436
663
  # nulls rather than dropping the entry — the count must still reconcile with
@@ -440,16 +667,10 @@ module Mutineer
440
667
  # @param result [Mutineer::Result] an errored or timed-out result.
441
668
  # @return [Hash] no-verdict JSON object.
442
669
  def no_verdict_json(result)
443
- file = result.subject&.file
444
- line =
445
- if result.mutation && file
446
- source = @source_map[file] || File.read(file)
447
- source.byteslice(0, result.mutation.start_offset).count("\n") + 1
448
- end
449
670
  {
450
671
  subject: result.subject&.qualified_name,
451
- file: file,
452
- line: line,
672
+ file: result.subject&.file,
673
+ line: result_line(result),
453
674
  id: result.id,
454
675
  status: result.status.to_s,
455
676
  details: result.details
@@ -465,8 +686,8 @@ module Mutineer
465
686
  out.puts "-------"
466
687
  out.puts format("Total: %-6d Killed: %d", @agg.total, @agg.killed_count)
467
688
  out.puts format("Survived: %-6d No coverage: %d", @agg.survived_count, @agg.no_coverage_count)
468
- out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count,
469
- @agg.errored_count + @agg.timeout_count)
689
+ out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count, @agg.errored_count)
690
+ out.puts format("Timeout: %-6d (over the per-mutant time limit)", @agg.timeout_count)
470
691
  # A broken harness, not a coverage gap: report it distinctly from No coverage.
471
692
  out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
472
693
  # Equivalent mutants the user suppressed; excluded from the denominator.
@@ -482,7 +703,7 @@ module Mutineer
482
703
  score = @agg.mutation_score
483
704
  excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
484
705
  "#{@agg.skipped_invalid_count} skipped, " \
485
- "#{@agg.errored_count + @agg.timeout_count} errored, " \
706
+ "#{@agg.errored_count} errored, #{@agg.timeout_count} timeout, " \
486
707
  "#{@agg.ignored_count} ignored excluded"
487
708
  if score.nil?
488
709
  out.puts "Mutation score: N/A (no covered mutants)"
@@ -581,7 +802,7 @@ module Mutineer
581
802
  sources.sort.each do |file, agg|
582
803
  score = agg.mutation_score
583
804
  out.puts format("%s %s (%d killed / %d survived / %d no-cov)",
584
- file, score.nil? ? "N/A" : "#{score}%",
805
+ printable(file), score.nil? ? "N/A" : "#{score}%",
585
806
  agg.killed_count, agg.survived_count, agg.no_coverage_count)
586
807
  end
587
808
  end
@@ -600,7 +821,7 @@ module Mutineer
600
821
  file = r.subject.file
601
822
  source = @source_map[file] || File.read(file)
602
823
  line, = diff_for(r.mutation, source)
603
- out.puts " + #{r.subject.qualified_name} (#{file}:#{line}) #{r.mutation.operator}"
824
+ out.puts " + #{printable(r.subject.qualified_name)} (#{printable(file)}:#{line}) #{r.mutation.operator}"
604
825
  end
605
826
  out.puts "score dropped #{delta.score_before}% -> #{delta.score_after}%" if delta.score_drop
606
827
  # An OK verdict must not imply a check that never ran: say when the score
@@ -622,7 +843,7 @@ module Mutineer
622
843
  out.puts "-----------------"
623
844
  mutants.group_by { |r| r.subject.file }.sort.each do |file, group|
624
845
  out.puts
625
- out.puts file
846
+ out.puts printable(file)
626
847
  group.sort_by { |r| r.mutation.start_offset }.each { |r| survivor(out, file, r) }
627
848
  end
628
849
  end
@@ -638,10 +859,25 @@ module Mutineer
638
859
  source = @source_map[file] || File.read(file)
639
860
  start_line, original_block, mutated_block, token = diff_for(m, source)
640
861
 
641
- out.puts " #{result.subject.qualified_name} (#{File.basename(file)}:#{start_line})"
642
- out.puts " Operator: #{m.operator} (#{token} -> #{m.replacement})"
643
- original_block.each_line { |l| out.puts " - #{l.chomp}" }
644
- mutated_block.each_line { |l| out.puts " + #{l.chomp}" }
862
+ out.puts " #{printable(result.subject.qualified_name)} (#{printable(File.basename(file))}:#{start_line})"
863
+ out.puts " Operator: #{m.operator} (#{printable(token)} -> #{printable(m.replacement)})"
864
+ original_block.each_line { |l| out.puts " - #{printable(l.chomp)}" }
865
+ mutated_block.each_line { |l| out.puts " + #{printable(l.chomp)}" }
866
+ end
867
+
868
+ # Source text, a method name or a file path made safe for a terminal (#163):
869
+ # each control character except tab becomes its Ruby escape (ESC prints as
870
+ # `\e`). The text is read as UTF-8 whatever the locale, and each byte that is
871
+ # not valid UTF-8 (a Latin-1 source, say) prints as `\xNN`, so nothing is
872
+ # lost and the regex never raises. JSON and HTML escape on their own.
873
+ #
874
+ # @api private
875
+ # @param text [String] source text.
876
+ # @return [String] the text with control characters and invalid bytes escaped.
877
+ def printable(text)
878
+ text.dup.force_encoding(Encoding::UTF_8)
879
+ .scrub { |bytes| bytes.unpack("C*").map { |b| format("\\x%02X", b) }.join }
880
+ .gsub(/[[:cntrl:]&&[^\t]]/) { |c| c.dump[1..-2] }
645
881
  end
646
882
 
647
883
  # Writes the final verdict line.
@@ -30,7 +30,28 @@ module Mutineer
30
30
  # them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
31
31
  # can render survivor diffs and emit the id. `id` is the content-based
32
32
  # MutantId (it includes the project-relative file path).
33
- Result = Data.define(:status, :details, :subject, :mutation, :id) do
33
+ #
34
+ # `kills` is nil except in a `--matrix` run, where a mutant that was forked
35
+ # carries the {Kills} its child reported. It annotates the verdict and never
36
+ # decides it.
37
+ #
38
+ # A Result crosses a process boundary only through WorkerPool's Marshal pipe,
39
+ # from a fork back to the process that forked it, so both ends always run the
40
+ # same code. Nothing persists a marshaled Result (reports, baselines and the
41
+ # coverage cache are JSON), so a new field needs no Marshal compatibility.
42
+ Result = Data.define(:status, :details, :subject, :mutation, :id, :kills) do
43
+ # Every field but `status` defaults to nil.
44
+ #
45
+ # @param status [Symbol] the outcome.
46
+ # @param details [String, nil] error or skip details.
47
+ # @param subject [Mutineer::Subject, nil] the mutated subject.
48
+ # @param mutation [Mutineer::Mutation, nil] the mutation.
49
+ # @param id [String, nil] the stable mutant id.
50
+ # @param kills [Mutineer::Kills, nil] the matrix row of a `--matrix` run.
51
+ def initialize(status:, details: nil, subject: nil, mutation: nil, id: nil, kills: nil)
52
+ super
53
+ end
54
+
34
55
  # Builds a killed result.
35
56
  #
36
57
  # @return [Mutineer::Result] killed result.
@@ -92,6 +113,21 @@ module Mutineer
92
113
 
93
114
  end
94
115
 
116
+ # One mutant's row of the kill matrix (`--matrix`): which tests killed it and
117
+ # which ran against it. A test is a `[file, name, id]` triple: the
118
+ # project-relative file that defines it, its name (`CalculatorTest#test_add`,
119
+ # or an RSpec example's full description), and an id that tells apart tests
120
+ # sharing a file and name (an RSpec example id; the name again for Minitest).
121
+ # File and id identify a test; the name is display data that a mutant can
122
+ # change. Both lists are sorted and unique, and `ran` includes the killers.
123
+ #
124
+ # `complete` is true when the child ran its whole suite and reported it
125
+ # cleanly (see Isolation.finish). It is false when the child timed out,
126
+ # errored or exited the process early, when the recorder never armed, when
127
+ # its lines arrived out of order, when Minitest returned after an Interrupt
128
+ # with tests unseen, or when the row does not agree with the verdict.
129
+ Kills = Data.define(:killed_by, :ran, :complete)
130
+
95
131
  # Aggregates a flat list of Results into counts, the mutation score, and the
96
132
  # surviving-mutant list. The score denominator is killed + survived ONLY:
97
133
  # no-coverage, uncapturable, skipped (invalid), errored, timeout, and ignored