mutineer 1.6.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.7"
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 "========================="
@@ -220,7 +233,7 @@ module Mutineer
220
233
  # Equivalent mutants the user suppressed: emitted with their stable id so
221
234
  # the user can audit what is silenced (and copy ids for survivors they
222
235
  # want to add). Excluded from the score; never in `survivors`.
223
- ignored: @agg.results.select(&:ignored?).map { |r| mutant_json(r) }
236
+ ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
224
237
  .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
225
238
  # Per-source breakdown (additive; baseline consumes it). Sorted by file so
226
239
  # output is byte-stable. Reuses AggregateResult via by_source.
@@ -284,6 +297,7 @@ module Mutineer
284
297
  #{summary_html}
285
298
  #{per_source_html(per_source)}
286
299
  #{survivors_html(survivors)}
300
+ #{ignored_html}
287
301
  #{matrix_html}
288
302
  </body>
289
303
  </html>
@@ -345,6 +359,28 @@ module Mutineer
345
359
  "<h2>Surviving Mutants</h2>\n#{cards}"
346
360
  end
347
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
+
348
384
  # The kill-matrix section of the HTML report: the counts, then the blind and
349
385
  # redundant tests. Empty without --matrix.
350
386
  #
@@ -631,9 +667,25 @@ module Mutineer
631
667
  lines.size == 1 ? start_line.to_s : "#{start_line},#{lines.size}"
632
668
  end
633
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
+
634
682
  # The fields that name one mutant, for the lists that point at mutants:
635
683
  # `no_coverage`, `uncapturable`, `unplaceable`, `ran_at_load`, `ignored` and
636
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.
637
689
  def mutant_json(result)
638
690
  m = result.mutation
639
691
  file = result.subject.file
@@ -700,8 +752,9 @@ module Mutineer
700
752
  out.puts format("Timeout: %-6d (over the per-mutant time limit)", @agg.timeout_count)
701
753
  # A broken harness, not a coverage gap: report it distinctly from No coverage.
702
754
  out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
703
- # Not broken: redefine has no named class to load these onto; reload runs them.
704
- out.puts format("Unplaceable: %-6d (class cannot be named statically; --strategy reload runs these)",
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)",
705
758
  @agg.unplaceable_count)
706
759
  # Not a verdict: the line ran before the mutant was applied (#187).
707
760
  out.puts format("Ran at load: %-6d (ran while the app or class loaded; mutineer cannot re-run that; " \
@@ -765,7 +818,8 @@ module Mutineer
765
818
  end
766
819
 
767
820
  # Mutants that were actually run. Deliberately not `total`: no_coverage,
768
- # 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
769
823
  # would dilute the share and let a broken run slip under the limit.
770
824
  #
771
825
  # skipped-invalid is excluded from both sides: it means a mutant did not
@@ -8,17 +8,24 @@ module Mutineer
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.
16
- # unplaceable - under `--strategy redefine`, the method belongs to a class
17
- # or module that cannot be named statically, so it has no
18
- # owner to be loaded onto and is not run. Excluded from the
19
- # denominator and, unlike uncapturable, from the
20
- # no-verdict gate: nothing is broken. `--strategy reload`
21
- # runs these mutants.
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.
22
29
  # ran_at_load - the mutated line ran while the app booted or its class
23
30
  # loaded. That run happened before the mutant was applied,
24
31
  # so a test can check a value the original code computed.
@@ -34,10 +41,10 @@ module Mutineer
34
41
  #
35
42
  # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
36
43
  # validity failure (counted separately by the reporter), error is a runtime
37
- # crash. Never conflate them via `details` string parsing. `no_coverage`,
38
- # `uncapturable` and `unplaceable` are pre-fork results, and `ran_at_load` is
39
- # pre-fork or a reclassified survivor: all excluded from the score
40
- # 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.
41
48
  #
42
49
  # `subject`, `mutation`, and `id` are nil when the Result is built by
43
50
  # Isolation/Runner (which only know the outcome); the orchestrator attaches
@@ -53,8 +60,9 @@ module Mutineer
53
60
  # from a fork back to the process that forked it, so both ends always run the
54
61
  # same code. Nothing persists a marshaled Result (reports, baselines and the
55
62
  # coverage cache are JSON), so a new field needs no Marshal compatibility.
56
- Result = Data.define(:status, :details, :subject, :mutation, :id, :kills) do
57
- # 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.
58
66
  #
59
67
  # @param status [Symbol] the outcome.
60
68
  # @param details [String, nil] error or skip details.
@@ -62,7 +70,8 @@ module Mutineer
62
70
  # @param mutation [Mutineer::Mutation, nil] the mutation.
63
71
  # @param id [String, nil] the stable mutant id.
64
72
  # @param kills [Mutineer::Kills, nil] the matrix row of a `--matrix` run.
65
- 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)
66
75
  super
67
76
  end
68
77
 
@@ -16,6 +16,7 @@ require_relative "file_swap"
16
16
  require_relative "external_backend"
17
17
  require_relative "daemon_backend"
18
18
  require_relative "job_plan"
19
+ require_relative "rails_run_env"
19
20
 
20
21
  module Mutineer
21
22
  # Orchestrates one mutation end-to-end: apply it textually, validate the
@@ -43,6 +44,10 @@ module Mutineer
43
44
  # @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
44
45
  # source map, and run extras.
45
46
  def self.execute(config)
47
+ # Rails reads PARALLEL_WORKERS when a test helper calls parallelize.
48
+ # Set it before any boot or child spawn, then put the caller's value back.
49
+ previous_parallel_workers = ENV["PARALLEL_WORKERS"]
50
+ RailsRunEnv.pin_process!
46
51
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
47
52
 
48
53
  # External backend: run the suite as a subprocess in the app's own runtime.
@@ -55,6 +60,15 @@ module Mutineer
55
60
  # payloads (Prism), so branch before any in-process boot.
56
61
  return DaemonBackend.execute(config, operator_classes) if config.daemon
57
62
 
63
+ # Whole-file reload writes .mutineer_mutant*.rb into each source dir (so
64
+ # require_relative resolves). A SIGKILL'd child skips the tempfile's
65
+ # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
66
+ # PARENT sweeps each source dir before and after the run. Orphans are
67
+ # impossible after a normal run. The first sweep runs before the boot: a
68
+ # file an older version left without the dot would stop Zeitwerk (#253).
69
+ dirs = JobPlan.source_dirs(config)
70
+ JobPlan.sweep_orphans(dirs)
71
+
58
72
  # Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
59
73
  # in the parent and inherited by every fork. Do NOT manually require the
60
74
  # sources. Under Zeitwerk a manual require of an autoloadable file raises;
@@ -102,6 +116,7 @@ module Mutineer
102
116
  boot_path: File.expand_path(config.boot, config.project_root),
103
117
  require_paths: config.require_paths, # loaded above; here only for the cache digest
104
118
  verbose: config.verbose,
119
+ coverage_from: config.coverage_from && File.expand_path(config.coverage_from, config.project_root),
105
120
  capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
106
121
  ).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
107
122
  # Nothing reads Coverage once the map is built (cache hit or not), so
@@ -127,15 +142,7 @@ module Mutineer
127
142
  # Collect every (subject, mutation) up front so the pool can fan them out.
128
143
  jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
129
144
 
130
- jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
131
-
132
- # Whole-file reload writes mutineer_mutant*.rb into each source dir (so
133
- # require_relative resolves). A SIGKILL'd child skips the tempfile's
134
- # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
135
- # PARENT sweeps each source dir before and after the run. Orphans are
136
- # impossible after a normal run.
137
- dirs = JobPlan.source_dirs(config)
138
- JobPlan.sweep_orphans(dirs)
145
+ jobs, ignored_results, extras[:unscoped_mutants] = JobPlan.scope_since(jobs, ignored_results, source_map, config)
139
146
 
140
147
  strategy = config.strategy
141
148
  # Fail-fast must be serial: a parallel stop_when fires on the first survivor
@@ -165,6 +172,9 @@ module Mutineer
165
172
  end
166
173
 
167
174
  [AggregateResult.new(results + ignored_results), source_map, extras]
175
+ ensure
176
+ # A caller that set PARALLEL_WORKERS keeps that value after the run.
177
+ RailsRunEnv.restore_process!(previous_parallel_workers)
168
178
  end
169
179
 
170
180
  # A `--matrix` error that carries no {Kills} row (the worker crashed, or its
@@ -206,7 +216,7 @@ module Mutineer
206
216
  FileSwap.restore_orphans(dirs)
207
217
 
208
218
  jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
209
- jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
219
+ jobs, ignored_results, extras[:unscoped_mutants] = JobPlan.scope_since(jobs, ignored_results, source_map, config)
210
220
 
211
221
  # Nothing to mutate: return before the smoke check, which runs the whole
212
222
  # --test set to calibrate a timeout no mutant would use (#76).
@@ -316,7 +326,8 @@ module Mutineer
316
326
  # narrow identically (score parity).
317
327
  kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
318
328
  return payload if kind == :verdict
319
- return Result.unplaceable if strategy == "redefine" && subject&.owner_unknown
329
+ # A :maybe mutant that cannot be placed stays no_coverage, as before #254.
330
+ return kind == :maybe ? Result.no_coverage : Result.unplaceable if subject&.unplaceable?(strategy)
320
331
 
321
332
  abs_tests = payload
322
333
 
@@ -338,7 +349,8 @@ module Mutineer
338
349
  end
339
350
  end
340
351
  result = relative_kills(result, coverage_map.project_root)
341
- JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
352
+ result = JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
353
+ JobPlan.selection_verdict(kind, result)
342
354
  end
343
355
 
344
356
  # Rewrites the test files of a result's {Kills} row relative to the project
@@ -12,12 +12,17 @@ module Mutineer
12
12
  # @param def_node [Prism::DefNode] the method that holds the position.
13
13
  # @param source [String] the source text of the file.
14
14
  # @param offset [Integer] byte offset of the position.
15
+ # @param loose [Boolean] also return the lines when the position is in
16
+ # code that runs only sometimes when the statement runs (the right side of
17
+ # `a && b`, a branch, the body of `x if c`). The tests that ran the
18
+ # statement then include every test that ran the position, but may not
19
+ # have run it (#254).
15
20
  # @return [Array<Integer>] the lines of the statement, with the body of each
16
21
  # heredoc in it. Empty when no statement in the body holds the position.
17
22
  # A first or last line that holds other code is left out, because Ruby
18
23
  # can count that line without the statement: the `def` line counts when
19
24
  # the file loads, and `x if done; y(` counts when `y` does not run.
20
- def self.for(def_node, source, offset)
25
+ def self.for(def_node, source, offset, loose: false)
21
26
  return [] unless def_node.body
22
27
 
23
28
  path = path_to(def_node.body, offset) || []
@@ -25,13 +30,15 @@ module Mutineer
25
30
  return [] unless index
26
31
 
27
32
  statement = path[index]
28
- # Code that runs only sometimes inside the statement: the tests that ran
29
- # the statement need not have run it.
30
- return [] if path[index..].each_cons(2).any? { |parent, child| sometimes?(parent, child) }
31
-
32
- # Ruby counts the line of `x while c` or `x if c` when it checks `c`,
33
- # also when `x` never runs. The path is the modifier, its body, statement.
34
- return [] if index >= 2 && modifier?(path[index - 2])
33
+ unless loose
34
+ # Code that runs only sometimes inside the statement: the tests that ran
35
+ # the statement need not have run it.
36
+ return [] if path[index..].each_cons(2).any? { |parent, child| sometimes?(parent, child) }
37
+
38
+ # Ruby counts the line of `x while c` or `x if c` when it checks `c`,
39
+ # also when `x` never runs. The path is the modifier, its body, statement.
40
+ return [] if index >= 2 && modifier?(path[index - 2])
41
+ end
35
42
 
36
43
  location = statement.location
37
44
  lines = (location.start_line..last_line(statement)).to_a
@@ -8,11 +8,17 @@ module Mutineer
8
8
  # (`module ::X` inside `Outer` owns into `X`); `lexical` is the class/module
9
9
  # chain as written (`["Outer", "::X"]`), which the redefine strategy needs to
10
10
  # rebuild the same Module.nesting as a whole-file reload (#145). `block_owner`
11
- # is the constant a `Data.define`-style block is assigned to, as written;
12
- # `owner_unknown` is true when the class that block builds cannot be named,
13
- # so the redefine strategy cannot load a mutant onto it.
11
+ # is the constant a `Data.define`-style block is assigned to, as written, or
12
+ # the `ClassMethods` of a concern's `class_methods` block (#252);
13
+ # `owner_unknown` is true when the class that owns the method cannot be named
14
+ # (an unassigned builder block, or a concern block whose class or module no
15
+ # constant names, such as an `included` block that runs in each includer),
16
+ # so the redefine strategy cannot load a mutant onto it. `hook_block` is true
17
+ # for a def anywhere inside a concern's `included` or `prepended` block, which
18
+ # ActiveSupport runs only when a class first includes the concern, so the
19
+ # reload strategy cannot load a mutant onto it either (#256).
14
20
  Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, :block_owner, :owner_unknown,
15
- keyword_init: true) do
21
+ :hook_block, keyword_init: true) do
16
22
  # Returns the fully-qualified subject name.
17
23
  #
18
24
  # @return [String] namespaced method name like `Billing::Invoice#total`.
@@ -28,6 +34,18 @@ module Mutineer
28
34
  lexical || namespace
29
35
  end
30
36
 
37
+ # True when the strategy cannot load a mutant of this method where the tests
38
+ # call it, so the mutant is not run. Redefine needs a named owner. Reload
39
+ # loads the whole file again, which redefines everything except what a
40
+ # concern's `included` or `prepended` block defines: that block does not run
41
+ # again in a class that already includes the concern.
42
+ #
43
+ # @param strategy [String] `"reload"` or `"redefine"`.
44
+ # @return [Boolean]
45
+ def unplaceable?(strategy)
46
+ strategy == "redefine" ? owner_unknown == true : hook_block == true
47
+ end
48
+
31
49
  # Returns the body location for the subject, if any.
32
50
  #
33
51
  # @return [Prism::Location, nil] body location or nil for empty methods.
@@ -12,7 +12,8 @@ module Mutineer
12
12
  # Child-process-only RSpec runner.
13
13
  #
14
14
  # Mirrors MinitestIntegration's contract: run the given spec files and
15
- # return 0 (all passed) or 1 (any failure).
15
+ # return 0 (all passed), 1 (an example failed) or 2 (only an error
16
+ # outside examples, such as a spec file that raised while loading).
16
17
  module RSpec
17
18
  # Runs the given RSpec files.
18
19
  #
@@ -21,7 +22,7 @@ module Mutineer
21
22
  # first failing example (RSpec `--fail-fast`).
22
23
  # @param record_to [IO, nil] a `--matrix` run: send each example's outcome
23
24
  # to this channel (see {KillFormatter}) and run every example.
24
- # @return [Integer] 0 on success, 1 on failure.
25
+ # @return [Integer] 0 on success, else {.failure_code}.
25
26
  # @raise [ArgumentError] when both options are given: a matrix run never stops.
26
27
  def self.run(spec_files, stop_at_first_failure: false, record_to: nil)
27
28
  if stop_at_first_failure && record_to
@@ -56,11 +57,22 @@ module Mutineer
56
57
  # early, so `end` needs a reported outcome for every planned example.
57
58
  KillChannel.write_end(record_to) if record_to && KillFormatter.last&.saw_every_example?
58
59
 
59
- status.zero? ? 0 : 1
60
+ status.zero? ? 0 : failure_code
60
61
  ensure
61
62
  FileOrder.rank = nil
62
63
  end
63
64
 
65
+ # The exit status of a failed run: 2 when no example failed and RSpec
66
+ # recorded an error outside examples, else 1.
67
+ #
68
+ # @api private
69
+ # @return [Integer] 1 or 2.
70
+ def self.failure_code
71
+ world = ::RSpec.world
72
+ outside_examples = world.respond_to?(:non_example_failure) && world.non_example_failure
73
+ outside_examples && ::RSpec.configuration.reporter.failed_examples.empty? ? 2 : 1
74
+ end
75
+
64
76
  # Runs the top-level example groups in the order of `spec_files` (#203),
65
77
  # after RSpec's own ordering, which then decides the order within a file.
66
78
  #
@@ -7,8 +7,9 @@ module Mutineer
7
7
  # Picks the test-framework runner.
8
8
  #
9
9
  # Each runner responds to
10
- # `.run(files, stop_at_first_failure: false, record_to: nil) -> 0/1`
11
- # (0 = all passed, 1 = any failure) and is called only inside a forked child.
10
+ # `.run(files, stop_at_first_failure: false, record_to: nil) -> 0/1/2`
11
+ # (0 = all passed, 1 = any failure, 2 = only an error outside tests, which
12
+ # the RSpec runner reports) and is called only inside a forked child.
12
13
  # Only the mutant path passes `stop_at_first_failure: true`; a `--matrix`
13
14
  # mutant run passes `record_to:` instead (a {KillChannel} pipe) and runs every
14
15
  # test. The fork boundary silences stdout (see ChildStdout); the runners do not.
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "yaml"
6
+
7
+ module Mutineer
8
+ # Prints `.mutineer.yml` `ignore:` entries for survivors in a JSON report.
9
+ # The command writes to stdout and does not edit files. The user pastes the
10
+ # lines under `ignore:`.
11
+ module Triage
12
+ # Usage line for a bad invocation.
13
+ USAGE = "Usage: mutineer triage REPORT.json --reason TEXT (--id ID ... | --all)"
14
+
15
+ # Reads argv, prints the entries, and returns the process exit code.
16
+ # 0 is success. 2 is a usage error.
17
+ #
18
+ # @param argv [Array<String>] arguments after the `triage` command.
19
+ # @param out [IO] where the entries go.
20
+ # @param err [IO] where usage errors go.
21
+ # @return [Integer] 0 or 2.
22
+ def self.run(argv, out: $stdout, err: $stderr)
23
+ reason = nil
24
+ ids = []
25
+ all = false
26
+ parser = OptionParser.new do |o|
27
+ o.banner = USAGE
28
+ o.on("--reason TEXT") { |text| reason = text }
29
+ o.on("--id ID") { |id| ids << id }
30
+ o.on("--all") { all = true }
31
+ end
32
+ begin
33
+ parser.parse!(argv)
34
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
35
+ err.puts "mutineer: #{e.message}"
36
+ return 2
37
+ end
38
+
39
+ report = argv.shift
40
+ if argv.any?
41
+ err.puts "mutineer: triage takes one report file"
42
+ return 2
43
+ end
44
+ if report.nil? || report.empty?
45
+ err.puts "mutineer: triage requires a JSON report file"
46
+ return 2
47
+ end
48
+ text = reason.to_s.strip
49
+ if reason.nil? || text.empty?
50
+ err.puts "mutineer: triage requires --reason TEXT"
51
+ return 2
52
+ end
53
+ if all && !ids.empty?
54
+ err.puts "mutineer: triage takes --id or --all, not both"
55
+ return 2
56
+ end
57
+ if !all && ids.empty?
58
+ err.puts "mutineer: triage requires --id ID or --all"
59
+ return 2
60
+ end
61
+
62
+ survivors = survivor_ids(report, err)
63
+ return 2 if survivors.nil?
64
+
65
+ chosen = all ? survivors : ids
66
+ missing = chosen.reject { |id| survivors.include?(id) }
67
+ unless missing.empty?
68
+ err.puts "mutineer: triage id is not a survivor: #{missing.join(', ')}"
69
+ return 2
70
+ end
71
+
72
+ entries = chosen.uniq.map { |id| { "id" => id, "reason" => text } }
73
+ if entries.empty?
74
+ err.puts "mutineer: triage has nothing to paste under ignore:"
75
+ return 0
76
+ end
77
+
78
+ body = YAML.dump(entries).sub(/\A---\n/, "")
79
+ out.write(body.end_with?("\n") ? body : "#{body}\n")
80
+ 0
81
+ end
82
+
83
+ # Survivor ids from a JSON report, in report order. Nil when the file
84
+ # cannot be used, after a message on `err`.
85
+ #
86
+ # @param path [String] report path.
87
+ # @param err [IO] usage errors.
88
+ # @return [Array<String>, nil]
89
+ def self.survivor_ids(path, err)
90
+ unless File.file?(path)
91
+ err.puts "mutineer: triage report not found: #{path}"
92
+ return nil
93
+ end
94
+
95
+ doc = JSON.parse(File.read(path))
96
+ rows = doc.is_a?(Hash) ? doc["survivors"] : nil
97
+ unless rows.is_a?(Array)
98
+ err.puts "mutineer: triage report has no survivors list"
99
+ return nil
100
+ end
101
+
102
+ rows.filter_map { |row| row["id"] if row.is_a?(Hash) && row["id"].is_a?(String) }
103
+ rescue JSON::ParserError => e
104
+ err.puts "mutineer: triage report is not JSON: #{e.message}"
105
+ nil
106
+ rescue SystemCallError
107
+ err.puts "mutineer: triage report cannot be read: #{path}"
108
+ nil
109
+ end
110
+ end
111
+ end