mutineer 1.5.0 → 1.7.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.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Mutineer
6
+ # Hides database credentials in text bound for a terminal: an error reply,
7
+ # a log line, a warning. Rails parses DATABASE_URL itself, so an error about
8
+ # a malformed one can quote the whole URL.
9
+ #
10
+ # Stdlib only: the app-side daemon loads this file without the rest of
11
+ # mutineer.
12
+ module Redaction
13
+ # The password in a `scheme://user:password@host` URL. Greedy up to the last
14
+ # `@` before an unescaped quote or line end, so a malformed password with a
15
+ # space, an unescaped `@`, or a quote `inspect` escaped (`\"`) is hidden too.
16
+ URL_PASSWORD = %r{([a-z][a-z0-9+.\-]*://[^\s:/@"'\\]*):(?:\\.|[^"'\r\n\\])*@}i
17
+ # What replaces a hidden password.
18
+ REDACTED = "[REDACTED]"
19
+
20
+ module_function
21
+
22
+ # Hide the password in any URL, and the DATABASE_URL password wherever it
23
+ # appears: whole, percent-decoded, `inspect` escaped, or cut short after
24
+ # its `scheme://user:` prefix.
25
+ #
26
+ # @param text [#to_s]
27
+ # @param env [Hash{String => String}] where DATABASE_URL is read.
28
+ # @return [String]
29
+ def text(text, env: ENV)
30
+ out = text.to_s
31
+ head, raw = database_url_parts(env["DATABASE_URL"].to_s)
32
+ secrets = raw ? password_forms(raw) : []
33
+ # Prefixes first, on the caller's text, so no replacement made here can
34
+ # be mistaken for part of a password.
35
+ [head, head&.inspect&.[](1...-1)].compact.uniq.each do |prefix|
36
+ out = secrets.reduce(out) { |acc, secret| redact_prefixes(acc, prefix, secret) }
37
+ end
38
+ out = out.gsub(URL_PASSWORD) { "#{Regexp.last_match(1)}:#{REDACTED}@" }
39
+ secrets.reduce(out) { |acc, secret| acc.gsub(secret, REDACTED) }
40
+ end
41
+
42
+ # Split DATABASE_URL into its `scheme://user:` head and its raw password.
43
+ # Read by position, not URI or a pattern, because the URL may be the
44
+ # malformed one and its password may hold any character: the password runs
45
+ # from the first `:` after `scheme://` to the last `@`.
46
+ #
47
+ # @param url [String]
48
+ # @return [Array(String, String), nil]
49
+ def database_url_parts(url)
50
+ start = url.index("://")
51
+ at = url.rindex("@")
52
+ return unless start && at
53
+
54
+ colon = url.index(":", start + 3)
55
+ return unless colon && colon < at
56
+
57
+ raw = url[(colon + 1)...at]
58
+ [url[0..colon], raw] unless raw.empty?
59
+ end
60
+
61
+ # The password as it may appear in a message: raw, percent-decoded, and
62
+ # each `inspect` escaped. Longest first, so a longer form is replaced
63
+ # before a form it contains.
64
+ #
65
+ # @param raw [String]
66
+ # @return [Array<String>]
67
+ def password_forms(raw)
68
+ decoded = begin
69
+ URI.decode_www_form_component(raw.gsub("+", "%2B"))
70
+ rescue ArgumentError
71
+ raw
72
+ end
73
+ [raw, decoded].flat_map { |form| [form, form.inspect[1...-1]] }
74
+ .uniq.reject(&:empty?).sort_by { |form| -form.length }
75
+ end
76
+
77
+ # Hide what follows each `head` while it matches the start of `secret`,
78
+ # for a message that cut the URL short inside the password.
79
+ #
80
+ # @param text [String]
81
+ # @param head [String] the DATABASE_URL `scheme://user:` prefix, raw or
82
+ # `inspect` escaped.
83
+ # @param secret [String]
84
+ # @return [String]
85
+ def redact_prefixes(text, head, secret)
86
+ out = +""
87
+ pos = 0
88
+ while (found = text.index(head, pos))
89
+ start = found + head.length
90
+ length = 0
91
+ length += 1 while length < secret.length && text[start + length] == secret[length]
92
+ out << text[pos...start] << (length.positive? ? REDACTED : "")
93
+ pos = start + length
94
+ end
95
+ out << text[pos..]
96
+ end
97
+ end
98
+ end
@@ -26,7 +26,7 @@ module Mutineer
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"
29
+ SCHEMA_VERSION = "1.8"
30
30
 
31
31
  # The warning both matrix renderers give under the redundant tests.
32
32
  MATRIX_REDUNDANT_NOTE = "Delete redundant tests one at a time: two of them can be the only killers of one mutant."
@@ -74,11 +74,11 @@ module Mutineer
74
74
  out.print rendered
75
75
  end
76
76
 
77
+ return warn_errors_without_gate(err) unless threshold&.positive?
78
+
77
79
  # Both ways a run can fail the gate on completeness, said here rather than in
78
80
  # the human renderer: --format json is the documented CI path, and a run that
79
81
  # exits 1 must say why on every format, not only the one a person reads.
80
- return unless threshold&.positive?
81
-
82
82
  if @agg.mutation_score.nil? && broken_nil_score?
83
83
  err.puts "[mutineer] nothing could be scored (#{broken_counts_detail}), so the " \
84
84
  "--threshold gate fails. See no_verdict[] in --format json for the cause of each."
@@ -89,6 +89,22 @@ module Mutineer
89
89
  end
90
90
  end
91
91
 
92
+ # Errors are out of the score, so a run with no --threshold passes however
93
+ # many mutants errored. Say so, and say what --threshold would enforce.
94
+ #
95
+ # @api private
96
+ # @param err [IO] error stream.
97
+ # @return [void]
98
+ def warn_errors_without_gate(err)
99
+ count = @agg.errored_count
100
+ return unless count.positive?
101
+
102
+ err.puts "[mutineer] #{count} #{count == 1 ? 'mutant' : 'mutants'} errored. Errors are not in the " \
103
+ "score, so they do not fail a run without a positive --threshold. With one, the run fails " \
104
+ "when nothing can be scored, or when more than one mutant has no verdict and they exceed " \
105
+ "#{(BROKEN_SHARE_LIMIT * 100).round}% of those attempted (no_verdict[] in --format json)."
106
+ end
107
+
92
108
  # Renders the human report.
93
109
  #
94
110
  # @param out [IO] output stream.
@@ -96,11 +112,8 @@ module Mutineer
96
112
  # @param threshold [Float] score threshold.
97
113
  # @return [void]
98
114
  def human_report(out, err, threshold)
99
- if @agg.total.zero?
100
- err.puts "No mutations generated — verify target files contain in-scope " \
101
- "operators and are reached by the suite."
102
- return
103
- end
115
+ # The CLI explains an empty run for every format (CLI.warn_empty_run).
116
+ return if @agg.total.zero?
104
117
 
105
118
  out.puts "Mutineer — Mutation Results"
106
119
  out.puts "========================="
@@ -167,6 +180,8 @@ module Mutineer
167
180
  total: @agg.total, killed: killed, survived: survived,
168
181
  no_coverage: @agg.no_coverage_count,
169
182
  uncapturable: @agg.uncapturable_count,
183
+ unplaceable: @agg.unplaceable_count,
184
+ ran_at_load: @agg.ran_at_load_count,
170
185
  skipped_invalid: @agg.skipped_invalid_count,
171
186
  errored: @agg.errored_count, timeout: @agg.timeout_count,
172
187
  ignored: @agg.ignored_count,
@@ -194,6 +209,12 @@ module Mutineer
194
209
  # Same shape as no_coverage; additive key.
195
210
  uncapturable: @agg.results.select(&:uncapturable?).map { |r| mutant_json(r) }
196
211
  .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
212
+ # Additive (1.7): owner-unknown mutants redefine did not run. Not in no_verdict.
213
+ unplaceable: @agg.results.select(&:unplaceable?).map { |r| mutant_json(r) }
214
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
215
+ # Additive (1.7, #187): mutants on a line that ran at load. Not in no_verdict.
216
+ ran_at_load: @agg.results.select(&:ran_at_load?).map { |r| mutant_json(r) }
217
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
197
218
  # Every mutant that was attempted and produced no verdict, whatever the
198
219
  # reason — the set the --threshold completeness gate counts. Named for the
199
220
  # condition rather than one status, because summary.errored means :error
@@ -212,7 +233,7 @@ module Mutineer
212
233
  # Equivalent mutants the user suppressed: emitted with their stable id so
213
234
  # the user can audit what is silenced (and copy ids for survivors they
214
235
  # want to add). Excluded from the score; never in `survivors`.
215
- ignored: @agg.results.select(&:ignored?).map { |r| mutant_json(r) }
236
+ ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
216
237
  .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
217
238
  # Per-source breakdown (additive; baseline consumes it). Sorted by file so
218
239
  # output is byte-stable. Reuses AggregateResult via by_source.
@@ -276,6 +297,7 @@ module Mutineer
276
297
  #{summary_html}
277
298
  #{per_source_html(per_source)}
278
299
  #{survivors_html(survivors)}
300
+ #{ignored_html}
279
301
  #{matrix_html}
280
302
  </body>
281
303
  </html>
@@ -287,7 +309,8 @@ module Mutineer
287
309
  counts = {
288
310
  "total" => @agg.total, "killed" => @agg.killed_count,
289
311
  "survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
290
- "uncapturable" => @agg.uncapturable_count, "ignored" => @agg.ignored_count,
312
+ "uncapturable" => @agg.uncapturable_count, "unplaceable" => @agg.unplaceable_count,
313
+ "ran_at_load" => @agg.ran_at_load_count, "ignored" => @agg.ignored_count,
291
314
  "skipped" => @agg.skipped_invalid_count,
292
315
  "errored" => @agg.errored_count, "timeout" => @agg.timeout_count
293
316
  }
@@ -336,6 +359,28 @@ module Mutineer
336
359
  "<h2>Surviving Mutants</h2>\n#{cards}"
337
360
  end
338
361
 
362
+ # Ignored mutants, each with its id and its reason when the user gave one.
363
+ #
364
+ # @api private
365
+ # @return [String] HTML, or "" when none were ignored.
366
+ def ignored_html
367
+ rows = @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
368
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] }
369
+ return "" if rows.empty?
370
+
371
+ cards = rows.map do |row|
372
+ reason = row[:reason] ? "<div class=\"meta\">#{esc(row[:reason])}</div>" : ""
373
+ <<~CARD.chomp
374
+ <div class="survivor">
375
+ <h3>#{esc(row[:subject])}</h3>
376
+ <div class="meta">#{esc(row[:file])}:#{row[:line]} &middot; #{esc(row[:operator])} &middot; <span class="id">#{esc(row[:id])}</span></div>
377
+ #{reason}
378
+ </div>
379
+ CARD
380
+ end.join("\n")
381
+ "<h2>Ignored mutants</h2>\n#{cards}"
382
+ end
383
+
339
384
  # The kill-matrix section of the HTML report: the counts, then the blind and
340
385
  # redundant tests. Empty without --matrix.
341
386
  #
@@ -622,8 +667,25 @@ module Mutineer
622
667
  lines.size == 1 ? start_line.to_s : "#{start_line},#{lines.size}"
623
668
  end
624
669
 
670
+ # One ignored mutant. `reason` is present only when the user wrote one.
671
+ #
672
+ # @api private
673
+ # @param result [Mutineer::Result] an ignored result.
674
+ # @return [Hash]
675
+ def ignored_json(result)
676
+ row = mutant_json(result)
677
+ text = result.reason
678
+ row[:reason] = text if text.is_a?(String) && !text.strip.empty?
679
+ row
680
+ end
681
+
625
682
  # The fields that name one mutant, for the lists that point at mutants:
626
- # `no_coverage`, `uncapturable`, `ignored` and `baseline.new_survivors`.
683
+ # `no_coverage`, `uncapturable`, `unplaceable`, `ran_at_load`, `ignored` and
684
+ # `baseline.new_survivors`.
685
+ #
686
+ # @api private
687
+ # @param result [Mutineer::Result] the mutant result.
688
+ # @return [Hash] subject, file, line, operator, token, and id.
627
689
  def mutant_json(result)
628
690
  m = result.mutation
629
691
  file = result.subject.file
@@ -690,6 +752,13 @@ module Mutineer
690
752
  out.puts format("Timeout: %-6d (over the per-mutant time limit)", @agg.timeout_count)
691
753
  # A broken harness, not a coverage gap: report it distinctly from No coverage.
692
754
  out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
755
+ # Not broken: the strategy cannot load these where the tests call them (no
756
+ # named class under redefine, or a concern's `included` or `prepended` block).
757
+ out.puts format("Unplaceable: %-6d (not loadable where the tests call it; verify with --test-command)",
758
+ @agg.unplaceable_count)
759
+ # Not a verdict: the line ran before the mutant was applied (#187).
760
+ out.puts format("Ran at load: %-6d (ran while the app or class loaded; mutineer cannot re-run that; " \
761
+ "verify with --test-command)", @agg.ran_at_load_count)
693
762
  # Equivalent mutants the user suppressed; excluded from the denominator.
694
763
  out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
695
764
  end
@@ -702,6 +771,7 @@ module Mutineer
702
771
  def score_line(out, err)
703
772
  score = @agg.mutation_score
704
773
  excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
774
+ "#{@agg.unplaceable_count} unplaceable, #{@agg.ran_at_load_count} ran at load, " \
705
775
  "#{@agg.skipped_invalid_count} skipped, " \
706
776
  "#{@agg.errored_count} errored, #{@agg.timeout_count} timeout, " \
707
777
  "#{@agg.ignored_count} ignored excluded"
@@ -748,7 +818,8 @@ module Mutineer
748
818
  end
749
819
 
750
820
  # Mutants that were actually run. Deliberately not `total`: no_coverage,
751
- # skipped-invalid and ignored mutants were never attempted, so counting them
821
+ # skipped-invalid and ignored mutants are not counted (a #254 no_coverage
822
+ # mutant ran its statement's tests but is not a verdict), so counting them
752
823
  # would dilute the share and let a broken run slip under the limit.
753
824
  #
754
825
  # skipped-invalid is excluded from both sides: it means a mutant did not
@@ -1,18 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- # Immutable outcome of running one mutant. Eight distinct states:
4
+ # Immutable outcome of running one mutant. Ten distinct states:
5
5
  # killed - a test failed/errored, so the mutation was caught.
6
6
  # survived - every test passed, so the mutation went undetected.
7
7
  # error - the child crashed (unhandled exception): exit status 2.
8
8
  # timeout - the parent SIGKILLed a child that overran its wall clock.
9
9
  # skipped - the mutated source failed to re-parse (invalid); no fork.
10
10
  # no_coverage - no test exercises the mutated line; not run, not scored.
11
+ # Also a mutant on a later line of a multi-line statement,
12
+ # in code that runs only sometimes, that the statement's
13
+ # tests ran but did not kill (#254): they may not reach it.
11
14
  # uncapturable - the line's would-be covering test errored during capture,
12
15
  # so coverage was lost. Excluded from the denominator exactly
13
16
  # like no_coverage, but reported separately: it signals a
14
17
  # broken harness (a test that failed to run), not a genuine
15
18
  # coverage gap.
19
+ # unplaceable - the strategy cannot load the mutant where the tests call
20
+ # it, so it is not run. Under `--strategy redefine` the
21
+ # method belongs to a class or module that cannot be named
22
+ # statically (for example each class that includes a
23
+ # concern). Under either strategy the method is in a
24
+ # concern's `included` or `prepended` block, which does not
25
+ # run again in a class that already includes it (#256).
26
+ # Excluded from the denominator and, unlike uncapturable,
27
+ # from the no-verdict gate: nothing is broken.
28
+ # `--test-command` runs these mutants.
29
+ # ran_at_load - the mutated line ran while the app booted or its class
30
+ # loaded. That run happened before the mutant was applied,
31
+ # so a test can check a value the original code computed.
32
+ # A survivor on such a line, and a line that ran only at
33
+ # load, get this status; a kill stays killed. Excluded from
34
+ # the denominator and from the no-verdict gate, like
35
+ # unplaceable. `--test-command` verifies these mutants.
16
36
  # ignored - a known-equivalent mutant the user suppressed, via an
17
37
  # inline `# mutineer:disable-line` comment or a
18
38
  # `.mutineer.yml` `ignore:` id. A pre-fork classification
@@ -21,9 +41,10 @@ module Mutineer
21
41
  #
22
42
  # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
23
43
  # validity failure (counted separately by the reporter), error is a runtime
24
- # crash. Never conflate them via `details` string parsing. `no_coverage` and
25
- # `uncapturable` are pre-fork selection results: both excluded from the score
26
- # denominator.
44
+ # crash. Never conflate them via `details` string parsing. `uncapturable` and
45
+ # `unplaceable` are pre-fork results, and `no_coverage` and `ran_at_load` are
46
+ # pre-fork or a reclassified result (JobPlan.selection_verdict,
47
+ # JobPlan.load_verdict): all excluded from the score denominator.
27
48
  #
28
49
  # `subject`, `mutation`, and `id` are nil when the Result is built by
29
50
  # Isolation/Runner (which only know the outcome); the orchestrator attaches
@@ -39,8 +60,9 @@ module Mutineer
39
60
  # from a fork back to the process that forked it, so both ends always run the
40
61
  # same code. Nothing persists a marshaled Result (reports, baselines and the
41
62
  # 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.
63
+ Result = Data.define(:status, :details, :subject, :mutation, :id, :kills, :reason) do
64
+ # Every field but `status` defaults to nil. `reason` is the text a user
65
+ # gave for an ignored mutant, from an inline marker or an `ignore:` mapping.
44
66
  #
45
67
  # @param status [Symbol] the outcome.
46
68
  # @param details [String, nil] error or skip details.
@@ -48,7 +70,8 @@ module Mutineer
48
70
  # @param mutation [Mutineer::Mutation, nil] the mutation.
49
71
  # @param id [String, nil] the stable mutant id.
50
72
  # @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)
73
+ # @param reason [String, nil] why an ignored mutant was suppressed.
74
+ def initialize(status:, details: nil, subject: nil, mutation: nil, id: nil, kills: nil, reason: nil)
52
75
  super
53
76
  end
54
77
 
@@ -89,6 +112,16 @@ module Mutineer
89
112
  # @return [Mutineer::Result] uncapturable result.
90
113
  def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
91
114
 
115
+ # Builds an unplaceable result.
116
+ #
117
+ # @return [Mutineer::Result] unplaceable result.
118
+ def self.unplaceable = new(status: :unplaceable, details: nil, subject: nil, mutation: nil, id: nil)
119
+
120
+ # Builds a ran_at_load result.
121
+ #
122
+ # @return [Mutineer::Result] ran-at-load result.
123
+ def self.ran_at_load = new(status: :ran_at_load, details: nil, subject: nil, mutation: nil, id: nil)
124
+
92
125
  # Builds an ignored result.
93
126
  #
94
127
  # @return [Mutineer::Result] ignored result.
@@ -108,6 +141,10 @@ module Mutineer
108
141
  def no_coverage? = status == :no_coverage
109
142
  # @return [Boolean] true when the status is uncapturable.
110
143
  def uncapturable? = status == :uncapturable
144
+ # @return [Boolean] true when the status is unplaceable.
145
+ def unplaceable? = status == :unplaceable
146
+ # @return [Boolean] true when the status is ran_at_load.
147
+ def ran_at_load? = status == :ran_at_load
111
148
  # @return [Boolean] true when the status is ignored.
112
149
  def ignored? = status == :ignored
113
150
 
@@ -130,7 +167,7 @@ module Mutineer
130
167
 
131
168
  # Aggregates a flat list of Results into counts, the mutation score, and the
132
169
  # surviving-mutant list. The score denominator is killed + survived ONLY:
133
- # no-coverage, uncapturable, skipped (invalid), errored, timeout, and ignored
170
+ # no-coverage, uncapturable, unplaceable, ran-at-load, skipped (invalid), errored, timeout, and ignored
134
171
  # (equivalent-mutant suppression) are each excluded and surfaced separately,
135
172
  # so suppressing every survivor reaches 100%. An empty denominator yields a
136
173
  # nil score (rendered "N/A"), never 0.0, distinguishing "no testable mutants"
@@ -154,6 +191,10 @@ module Mutineer
154
191
  def no_coverage_count = count(:no_coverage)
155
192
  # @return [Integer] uncapturable count.
156
193
  def uncapturable_count = count(:uncapturable)
194
+ # @return [Integer] unplaceable count.
195
+ def unplaceable_count = count(:unplaceable)
196
+ # @return [Integer] ran-at-load count.
197
+ def ran_at_load_count = count(:ran_at_load)
157
198
  # @return [Integer] skipped-invalid count.
158
199
  def skipped_invalid_count = count(:skipped)
159
200
  # @return [Integer] errored count.