mutineer 1.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +250 -0
- data/README.md +198 -27
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +112 -16
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +161 -14
- data/lib/mutineer/daemon_backend.rb +3 -0
- data/lib/mutineer/daemon_client.rb +170 -13
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/isolation.rb +91 -6
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +27 -10
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +7 -0
- data/lib/mutineer/rails_worker_db.rb +1 -1
- data/lib/mutineer/reporter.rb +288 -52
- data/lib/mutineer/result.rb +37 -1
- data/lib/mutineer/runner.rb +133 -16
- data/lib/mutineer/statement_lines.rb +143 -0
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +243 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +1 -0
- metadata +8 -3
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
176
|
-
|
|
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|
|
|
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[]
|
|
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|
|
|
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
|
|
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
|
|
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|
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|
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
|
-
#
|
|
384
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -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
|
-
|
|
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
|