mutineer 1.7.0 → 1.8.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +157 -747
  4. data/lib/mutineer/baseline.rb +1 -1
  5. data/lib/mutineer/changed_lines.rb +2 -1
  6. data/lib/mutineer/cli.rb +119 -27
  7. data/lib/mutineer/concern_blocks.rb +168 -5
  8. data/lib/mutineer/config.rb +6 -0
  9. data/lib/mutineer/coverage_map.rb +92 -17
  10. data/lib/mutineer/daemon_backend.rb +75 -32
  11. data/lib/mutineer/daemon_client.rb +50 -17
  12. data/lib/mutineer/daemon_server.rb +92 -40
  13. data/lib/mutineer/external_backend.rb +150 -17
  14. data/lib/mutineer/isolation.rb +67 -25
  15. data/lib/mutineer/job_plan.rb +117 -33
  16. data/lib/mutineer/kill_channel.rb +24 -14
  17. data/lib/mutineer/migrate.rb +10 -8
  18. data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
  19. data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
  20. data/lib/mutineer/minitest_integration.rb +44 -14
  21. data/lib/mutineer/mutators/array_literal.rb +4 -2
  22. data/lib/mutineer/mutators/base.rb +22 -0
  23. data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
  24. data/lib/mutineer/mutators/chain_link.rb +6 -6
  25. data/lib/mutineer/mutators/condition_forcing.rb +6 -3
  26. data/lib/mutineer/mutators/operand_removal.rb +4 -2
  27. data/lib/mutineer/mutators/range_literal.rb +105 -4
  28. data/lib/mutineer/mutators/return_nil.rb +1 -0
  29. data/lib/mutineer/orphan_guard.rb +18 -6
  30. data/lib/mutineer/pairing.rb +62 -1
  31. data/lib/mutineer/project.rb +226 -12
  32. data/lib/mutineer/rails_worker_db.rb +200 -16
  33. data/lib/mutineer/redaction.rb +112 -11
  34. data/lib/mutineer/reporter.rb +109 -21
  35. data/lib/mutineer/result.rb +33 -5
  36. data/lib/mutineer/runner.rb +126 -18
  37. data/lib/mutineer/statement_lines.rb +14 -8
  38. data/lib/mutineer/subject.rb +13 -5
  39. data/lib/mutineer/test_runners/minitest.rb +2 -1
  40. data/lib/mutineer/test_runners.rb +4 -2
  41. data/lib/mutineer/triage.rb +1 -1
  42. data/lib/mutineer/version.rb +1 -1
  43. data/lib/mutineer/worker_pool.rb +2 -1
  44. data/skills/mutineer/SKILL.md +84 -0
  45. metadata +5 -1
@@ -25,8 +25,13 @@ 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
+ # Share of the mutants a run tried to score that may go unrun (unplaceable or
29
+ # skipped invalid) before stderr warns. A warning only: it never changes the
30
+ # exit code. {BROKEN_FLOOR} also applies, so one unrun mutant never warns.
31
+ UNRUN_SHARE_LIMIT = 0.10
32
+
28
33
  # The JSON report's `schema_version` (see docs/json-schema.md).
29
- SCHEMA_VERSION = "1.8"
34
+ SCHEMA_VERSION = "1.9"
30
35
 
31
36
  # The warning both matrix renderers give under the redundant tests.
32
37
  MATRIX_REDUNDANT_NOTE = "Delete redundant tests one at a time: two of them can be the only killers of one mutant."
@@ -52,16 +57,18 @@ module Mutineer
52
57
  # `--baseline` load) knows the score covers only the changed-line mutants.
53
58
  # `legacy_id_matches` (`{ignore:, baseline:}`) counts stored ids still in the
54
59
  # old format (#126); only the JSON report records it (`summary.legacy_id_matches`).
60
+ # `declined` (operator name => count, from {JobPlan.collect_jobs}) counts the
61
+ # candidates the operators did not mutate; the JSON and human reports show it.
55
62
  def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
56
- baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
63
+ baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 }, declined: {})
57
64
  rendered =
58
65
  if format == "json"
59
- json_report(baseline, scoped: scoped, legacy_id_matches: legacy_id_matches)
66
+ json_report(baseline, scoped: scoped, legacy_id_matches: legacy_id_matches, declined: declined)
60
67
  elsif format == "html"
61
68
  html_report
62
69
  else
63
70
  sio = StringIO.new
64
- human_report(sio, err, threshold)
71
+ human_report(sio, err, threshold, declined: declined, scoped: scoped)
65
72
  baseline_section(sio, baseline) if baseline
66
73
  sio.string
67
74
  end
@@ -74,6 +81,7 @@ module Mutineer
74
81
  out.print rendered
75
82
  end
76
83
 
84
+ warn_unrun_share(err)
77
85
  return warn_errors_without_gate(err) unless threshold&.positive?
78
86
 
79
87
  # Both ways a run can fail the gate on completeness, said here rather than in
@@ -105,20 +113,53 @@ module Mutineer
105
113
  "#{(BROKEN_SHARE_LIMIT * 100).round}% of those attempted (no_verdict[] in --format json)."
106
114
  end
107
115
 
116
+ # Warns when the unrun mutants (unplaceable plus skipped invalid) are more
117
+ # than {UNRUN_SHARE_LIMIT} of the mutants the run tried to score, and more
118
+ # than {BROKEN_FLOOR}. Every format, with or without --threshold; the exit
119
+ # code does not change.
120
+ #
121
+ # @api private
122
+ # @param err [IO] error stream.
123
+ # @return [void]
124
+ def warn_unrun_share(err)
125
+ return unless unrun_share_exceeded?
126
+
127
+ unrun = @agg.unrun_count
128
+ tried = attempted_count + unrun
129
+ parts = []
130
+ parts << "#{@agg.unplaceable_count} unplaceable" if @agg.unplaceable_count.positive?
131
+ parts << "#{@agg.skipped_invalid_count} skipped (invalid)" if @agg.skipped_invalid_count.positive?
132
+ pct = (unrun * 100.0 / tried).round(1)
133
+ message = +"[mutineer] #{unrun} of the #{tried} mutants mutineer tried to score were not run " \
134
+ "(#{parts.join(', ')}; #{pct}%, limit #{(UNRUN_SHARE_LIMIT * 100).round}%). " \
135
+ "The score leaves them out. This warning does not change the exit code."
136
+ if @agg.unplaceable_count.positive?
137
+ message << " See unplaceable[] in --format json; --test-command runs them."
138
+ end
139
+ if @agg.skipped_invalid_count.positive?
140
+ message << " A mutant skipped (invalid) did not re-parse after mutation. If its source file " \
141
+ "parses on its own, that is a mutineer bug: please report it at " \
142
+ "https://github.com/davidteren/mutineer/issues."
143
+ end
144
+ err.puts message
145
+ end
146
+
108
147
  # Renders the human report.
109
148
  #
110
149
  # @param out [IO] output stream.
111
150
  # @param err [IO] error stream.
112
151
  # @param threshold [Float] score threshold.
152
+ # @param declined [Hash{String => Integer}] operator name => declined count.
153
+ # @param scoped [Boolean] the run was diff-scoped (`--since`).
113
154
  # @return [void]
114
- def human_report(out, err, threshold)
155
+ def human_report(out, err, threshold, declined: {}, scoped: false)
115
156
  # The CLI explains an empty run for every format (CLI.warn_empty_run).
116
157
  return if @agg.total.zero?
117
158
 
118
159
  out.puts "Mutineer — Mutation Results"
119
160
  out.puts "========================="
120
161
  out.puts
121
- summary(out)
162
+ summary(out, declined: declined, scoped: scoped)
122
163
  out.puts
123
164
  score_line(out, err)
124
165
  per_source(out)
@@ -149,7 +190,7 @@ module Mutineer
149
190
  # reports 90% and exits 0, so CI cannot tell a complete run from a broken one.
150
191
  return 1 if broken_share_exceeded?
151
192
 
152
- score >= threshold ? 0 : 1
193
+ @agg.meets_threshold?(threshold) ? 0 : 1
153
194
  end
154
195
 
155
196
  private
@@ -166,7 +207,10 @@ module Mutineer
166
207
  # old-format ignore entries that matched, and survivors matched in an
167
208
  # old-format baseline only through their old id (#126).
168
209
  # @return [String] JSON text.
169
- def json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
210
+ # @param declined [Hash{String => Integer}] operator name => count of
211
+ # candidates that operator did not mutate (additive `summary.declined`
212
+ # and `summary.declined_by_operator` keys).
213
+ def json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 }, declined: {})
170
214
  killed = @agg.killed_count
171
215
  survived = @agg.survived_count
172
216
  # null (not 0.0) on an empty denominator, matching the nil-vs-0.0
@@ -199,7 +243,11 @@ module Mutineer
199
243
  # Additive (1.4, #126): stored ids still in the old format. `ignore` is
200
244
  # the number of old-format ignore entries that matched; `baseline` the
201
245
  # survivors matched in the baseline only through their old id.
202
- legacy_id_matches: legacy_id_matches
246
+ legacy_id_matches: legacy_id_matches,
247
+ # Additive (1.9, #291): candidates the operators found and did not
248
+ # mutate (a heredoc split or an equivalent skip), so not in `total`.
249
+ declined: declined.values.sum,
250
+ declined_by_operator: declined
203
251
  },
204
252
  survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
205
253
  .sort_by { |h| [h[:file], h[:line], h[:operator]] },
@@ -521,7 +569,7 @@ module Mutineer
521
569
  file = result.subject&.file
522
570
  return unless result.mutation && file
523
571
 
524
- source = @source_map[file] || File.read(file)
572
+ source = source_for(file)
525
573
  source.byteslice(0, result.mutation.start_offset).count("\n") + 1
526
574
  end
527
575
 
@@ -608,7 +656,7 @@ module Mutineer
608
656
  def survivor_json(result)
609
657
  m = result.mutation
610
658
  file = result.subject.file
611
- source = @source_map[file] || File.read(file)
659
+ source = source_for(file)
612
660
  start_line, original_block, mutated_block, token = diff_for(m, source)
613
661
  # A block at the end of the file has no newline after it; elsewhere the
614
662
  # file's next byte is one. Each side's own text decides its marker.
@@ -689,7 +737,7 @@ module Mutineer
689
737
  def mutant_json(result)
690
738
  m = result.mutation
691
739
  file = result.subject.file
692
- source = @source_map[file] || File.read(file)
740
+ source = source_for(file)
693
741
  start_line, _orig, _mut, token = diff_for(m, source)
694
742
  {
695
743
  subject: result.subject.qualified_name,
@@ -742,8 +790,11 @@ module Mutineer
742
790
  # Writes the summary block.
743
791
  #
744
792
  # @param out [IO] output stream.
793
+ # @param declined [Hash{String => Integer}] operator name => declined count.
794
+ # @param scoped [Boolean] the run was diff-scoped (`--since`), which does not
795
+ # narrow the declined count.
745
796
  # @return [void]
746
- def summary(out)
797
+ def summary(out, declined: {}, scoped: false)
747
798
  out.puts "Summary"
748
799
  out.puts "-------"
749
800
  out.puts format("Total: %-6d Killed: %d", @agg.total, @agg.killed_count)
@@ -753,12 +804,16 @@ module Mutineer
753
804
  # A broken harness, not a coverage gap: report it distinctly from No coverage.
754
805
  out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
755
806
  # 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).
807
+ # named class under redefine, or some methods in a concern's `included` or
808
+ # `prepended` block; see docs/json-schema.md).
757
809
  out.puts format("Unplaceable: %-6d (not loadable where the tests call it; verify with --test-command)",
758
810
  @agg.unplaceable_count)
759
811
  # Not a verdict: the line ran before the mutant was applied (#187).
760
812
  out.puts format("Ran at load: %-6d (ran while the app or class loaded; mutineer cannot re-run that; " \
761
813
  "verify with --test-command)", @agg.ran_at_load_count)
814
+ # Not mutants: candidates an operator chose not to mutate (#291).
815
+ out.puts format("Declined: %-6d (candidates an operator did not mutate; --dry-run shows the count " \
816
+ "per operator%s)", declined.values.sum, scoped ? "; not narrowed by --since" : "")
762
817
  # Equivalent mutants the user suppressed; excluded from the denominator.
763
818
  out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
764
819
  end
@@ -824,8 +879,8 @@ module Mutineer
824
879
  #
825
880
  # skipped-invalid is excluded from both sides: it means a mutant did not
826
881
  # re-parse and was correctly never run, which is a validity outcome rather
827
- # than a broken harness. Cost: an overwhelmingly-skipped run still scores on
828
- # what little ran; that is our operator misbehaving and wants its own signal.
882
+ # than a broken harness. Its own signal is the unrun-share warning
883
+ # ({#warn_unrun_share}), together with unplaceable.
829
884
  #
830
885
  # @api private
831
886
  # @return [Integer] killed + survived + no-verdict.
@@ -833,6 +888,16 @@ module Mutineer
833
888
  @agg.killed_count + @agg.survived_count + no_verdict_count
834
889
  end
835
890
 
891
+ # Whether the unrun mutants (unplaceable plus skipped invalid) are more than
892
+ # {BROKEN_FLOOR} and more than {UNRUN_SHARE_LIMIT} of attempted plus unrun.
893
+ #
894
+ # @api private
895
+ # @return [Boolean] true when the unrun-share warning applies.
896
+ def unrun_share_exceeded?
897
+ unrun = @agg.unrun_count
898
+ unrun > BROKEN_FLOOR && unrun > UNRUN_SHARE_LIMIT * (attempted_count + unrun)
899
+ end
900
+
836
901
  # The sentence both the verdict line and the stderr note are built from, so a
837
902
  # user cannot read one number in the report and a different one beside it.
838
903
  #
@@ -887,10 +952,10 @@ module Mutineer
887
952
  out.puts "-------------------"
888
953
  out.puts "killed #{@agg.killed_count}, #{delta.new_survivors.size} new survivors vs baseline"
889
954
  delta.new_survivors
890
- .sort_by { |r| [r.subject.file, r.mutation.start_offset] }
955
+ .sort_by { |r| [r.subject.file, *tie_key(r)] }
891
956
  .each do |r|
892
957
  file = r.subject.file
893
- source = @source_map[file] || File.read(file)
958
+ source = source_for(file)
894
959
  line, = diff_for(r.mutation, source)
895
960
  out.puts " + #{printable(r.subject.qualified_name)} (#{printable(file)}:#{line}) #{r.mutation.operator}"
896
961
  end
@@ -901,6 +966,16 @@ module Mutineer
901
966
  out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
902
967
  end
903
968
 
969
+ # Orders mutants in one file: by offset, then operator and replacement, so
970
+ # mutants at the same offset list the same way whatever order they ran in.
971
+ #
972
+ # @param result [Mutineer::Result] a result with a mutation.
973
+ # @return [Array] the sort key.
974
+ def tie_key(result)
975
+ m = result.mutation
976
+ [m.start_offset, m.operator.to_s, m.replacement.to_s]
977
+ end
978
+
904
979
  # Writes the survivors block.
905
980
  #
906
981
  # @param out [IO] output stream.
@@ -915,7 +990,7 @@ module Mutineer
915
990
  mutants.group_by { |r| r.subject.file }.sort.each do |file, group|
916
991
  out.puts
917
992
  out.puts printable(file)
918
- group.sort_by { |r| r.mutation.start_offset }.each { |r| survivor(out, file, r) }
993
+ group.sort_by { |r| tie_key(r) }.each { |r| survivor(out, file, r) }
919
994
  end
920
995
  end
921
996
 
@@ -927,7 +1002,7 @@ module Mutineer
927
1002
  # @return [void]
928
1003
  def survivor(out, file, result)
929
1004
  m = result.mutation
930
- source = @source_map[file] || File.read(file)
1005
+ source = source_for(file)
931
1006
  start_line, original_block, mutated_block, token = diff_for(m, source)
932
1007
 
933
1008
  out.puts " #{printable(result.subject.qualified_name)} (#{printable(File.basename(file))}:#{start_line})"
@@ -936,6 +1011,14 @@ module Mutineer
936
1011
  mutated_block.each_line { |l| out.puts " + #{printable(l.chomp)}" }
937
1012
  end
938
1013
 
1014
+ # The source text of `file`: the run's copy when it has one, else the file
1015
+ # read as UTF-8 whatever the locale (#303).
1016
+ #
1017
+ # @api private
1018
+ # @param file [String] source file path.
1019
+ # @return [String] source text.
1020
+ def source_for(file) = @source_map[file] || File.read(file, encoding: "UTF-8")
1021
+
939
1022
  # Source text, a method name or a file path made safe for a terminal (#163):
940
1023
  # each control character except tab becomes its Ruby escape (ESC prints as
941
1024
  # `\e`). The text is read as UTF-8 whatever the locale, and each byte that is
@@ -968,9 +1051,14 @@ module Mutineer
968
1051
 
969
1052
  # Same rule as exit_code, or the report says PASSED on a run that exits 1 —
970
1053
  # and with --output that wrong verdict is what gets archived.
1054
+ passed = @agg.meets_threshold?(threshold)
971
1055
  if broken_share_exceeded?
972
1056
  out.puts "FAILED: #{no_verdict_ratio}; #{score}% covers only part of the run"
973
- elsif score >= threshold
1057
+ elsif passed != (score >= threshold)
1058
+ # The rounded score points the other way, so give the exact counts.
1059
+ out.puts "#{passed ? 'PASSED' : 'FAILED'}: #{@agg.killed_count} of #{@agg.covered_count} killed " \
1060
+ "#{passed ? 'meets' : 'is below'} threshold #{threshold}% (the #{score}% score is rounded)"
1061
+ elsif passed
974
1062
  out.puts "PASSED: #{score}% >= threshold #{threshold}%"
975
1063
  else
976
1064
  out.puts "FAILED: #{score}% < threshold #{threshold}%"
@@ -4,13 +4,18 @@ module Mutineer
4
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
- # error - the child crashed (unhandled exception): exit status 2.
7
+ # error - the child crashed (unhandled exception), or a Minitest
8
+ # run's first failure was a database connection or lock
9
+ # error (#296): exit status 2.
8
10
  # timeout - the parent SIGKILLed a child that overran its wall clock.
9
11
  # skipped - the mutated source failed to re-parse (invalid); no fork.
10
12
  # no_coverage - no test exercises the mutated line; not run, not scored.
11
13
  # Also a mutant on a later line of a multi-line statement,
12
14
  # in code that runs only sometimes, that the statement's
13
15
  # tests ran but did not kill (#254): they may not reach it.
16
+ # Also a mutant on a later line of a statement whose first
17
+ # or last line holds other code (a call inside a one-line
18
+ # block), that the tests of that line did not kill (#289).
14
19
  # uncapturable - the line's would-be covering test errored during capture,
15
20
  # so coverage was lost. Excluded from the denominator exactly
16
21
  # like no_coverage, but reported separately: it signals a
@@ -20,9 +25,11 @@ module Mutineer
20
25
  # it, so it is not run. Under `--strategy redefine` the
21
26
  # method belongs to a class or module that cannot be named
22
27
  # 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).
28
+ # concern), and every method in a concern's `included` or
29
+ # `prepended` block is one (#256). Under `--strategy reload`
30
+ # only some methods in such a block are: those it cannot
31
+ # load into each class that has them (#297; the cases are
32
+ # listed in docs/json-schema.md).
26
33
  # Excluded from the denominator and, unlike uncapturable,
27
34
  # from the no-verdict gate: nothing is broken.
28
35
  # `--test-command` runs these mutants.
@@ -162,7 +169,8 @@ module Mutineer
162
169
  # cleanly (see Isolation.finish). It is false when the child timed out,
163
170
  # errored or exited the process early, when the recorder never armed, when
164
171
  # its lines arrived out of order, when Minitest returned after an Interrupt
165
- # with tests unseen, or when the row does not agree with the verdict.
172
+ # with tests unseen, when a test had a database connection or lock failure,
173
+ # or when the row does not agree with the verdict.
166
174
  Kills = Data.define(:killed_by, :ran, :complete)
167
175
 
168
176
  # Aggregates a flat list of Results into counts, the mutation score, and the
@@ -203,6 +211,11 @@ module Mutineer
203
211
  def timeout_count = count(:timeout)
204
212
  # @return [Integer] ignored count.
205
213
  def ignored_count = count(:ignored)
214
+ # Mutants that exist but did not run: unplaceable plus skipped invalid.
215
+ # Neither is in the score; the reporter warns when they pass a share of the run.
216
+ #
217
+ # @return [Integer] unplaceable plus skipped-invalid count.
218
+ def unrun_count = unplaceable_count + skipped_invalid_count
206
219
 
207
220
  # Every generated, classified mutation. NOT the score denominator.
208
221
  #
@@ -223,6 +236,21 @@ module Mutineer
223
236
  (killed_count.to_f / covered_count * 100).round(1)
224
237
  end
225
238
 
239
+ # Checks the unrounded score against a threshold. `mutation_score` is
240
+ # rounded for display, so 1999 of 2000 killed reads 100.0; a gate that
241
+ # compared it would pass `--threshold 100` with a survivor. The comparison
242
+ # is exact: integer counts against the threshold as the decimal it prints
243
+ # as, so a float product such as `64.4 * 250` cannot tip a run at the
244
+ # boundary. `to_s.to_r` keeps that decimal; `rationalize` could round a
245
+ # long threshold such as 66.66666666666667 down to 200/3.
246
+ #
247
+ # @param threshold [Numeric] threshold percentage.
248
+ # @return [Boolean] true when killed / covered * 100 >= threshold; false
249
+ # when nothing was covered.
250
+ def meets_threshold?(threshold)
251
+ covered_count.positive? && killed_count * 100 >= threshold.to_s.to_r * covered_count
252
+ end
253
+
226
254
  # Returns the surviving mutants.
227
255
  #
228
256
  # @return [Array<Mutineer::Result>] surviving results.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require "rbconfig"
4
5
  require_relative "parser"
5
6
  require_relative "project"
6
7
  require_relative "result"
@@ -17,8 +18,27 @@ require_relative "external_backend"
17
18
  require_relative "daemon_backend"
18
19
  require_relative "job_plan"
19
20
  require_relative "rails_run_env"
21
+ require_relative "redaction"
20
22
 
21
23
  module Mutineer
24
+ # Raised when the tool process cannot load a project file: the boot file, a
25
+ # standalone source, or a `--require` path. The message is redacted and the
26
+ # original exception is not kept as `cause`, so neither can show the
27
+ # DATABASE_URL password. The CLI maps it to a runtime error (exit 1).
28
+ class ProjectLoadError < StandardError
29
+ # @return [Array<String>] the redacted `Class: message` of each exception
30
+ # in the original `cause` chain, outermost first.
31
+ attr_reader :causes
32
+
33
+ # @param message [String] the redacted message.
34
+ # @param causes [Array<String>] see {#causes}.
35
+ # @return [ProjectLoadError]
36
+ def initialize(message, causes: [])
37
+ super(message)
38
+ @causes = causes
39
+ end
40
+ end
41
+
22
42
  # Orchestrates one mutation end-to-end: apply it textually, validate the
23
43
  # result, select its covering test files from the coverage map, then run only
24
44
  # those against the mutated source in an isolated child process (strategy
@@ -32,7 +52,8 @@ module Mutineer
32
52
  # Full orchestration: resolve operators, discover subjects, build the
33
53
  # coverage map, run every mutation, and aggregate. Returns
34
54
  # [AggregateResult, source_map, extras], where extras is the hash
35
- # {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
55
+ # {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`,
56
+ # `:dropped_id_map`, `:declined`), plus `:unscoped_mutants` under `--since`.
36
57
  # The CLI then reports + applies the exit code; the integration test asserts
37
58
  # directly on the AggregateResult.
38
59
  #
@@ -58,16 +79,32 @@ module Mutineer
58
79
  # Daemon backend: boot the app ONCE in a persistent subprocess under the
59
80
  # app's bundle and fork per mutant. Tool-side we only discover jobs + build
60
81
  # payloads (Prism), so branch before any in-process boot.
61
- return DaemonBackend.execute(config, operator_classes) if config.daemon
82
+ if config.daemon
83
+ # The daemon loads these files itself and would report a missing one as
84
+ # a boot failure (exit 1). Check here so it stays a usage error (exit 2),
85
+ # as it is in process.
86
+ [config.boot, *config.require_paths].compact.each { |f| project_file!(config.project_root, f) }
87
+ return DaemonBackend.execute(config, operator_classes)
88
+ end
62
89
 
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
90
+ # Both strategies (whole-file reload and redefine, #279) write
91
+ # .mutineer_mutant*.rb into each source dir (so require_relative and
92
+ # __dir__ resolve). Redefine falls back to the system temp dir for a
93
+ # read-only source dir, which this sweep does not reach; the OS cleans
94
+ # it, as in 1.x. A SIGKILL'd child skips the tempfile's
65
95
  # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
66
96
  # PARENT sweeps each source dir before and after the run. Orphans are
67
97
  # impossible after a normal run. The first sweep runs before the boot: a
68
98
  # file an older version left without the dot would stop Zeitwerk (#253).
69
99
  dirs = JobPlan.source_dirs(config)
70
100
  JobPlan.sweep_orphans(dirs)
101
+ if config.strategy == "redefine"
102
+ dirs.reject { |d| File.writable?(d) }.each do |d|
103
+ warn "[mutineer] #{d} is not writable: redefine loads its mutants from the system temp dir, " \
104
+ "so a method there that uses __dir__ or require_relative can be falsely killed. " \
105
+ "Make the directory writable to avoid this (#339)."
106
+ end
107
+ end
71
108
 
72
109
  # Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
73
110
  # in the parent and inherited by every fork. Do NOT manually require the
@@ -92,11 +129,11 @@ module Mutineer
92
129
  when :idle then Coverage.start(lines: true, methods: true)
93
130
  when :suspended then Coverage.resume
94
131
  end
95
- require File.expand_path(config.boot, config.project_root)
132
+ require_project_file(config.project_root, config.boot)
96
133
  else
97
- config.sources.each { |f| require File.expand_path(f, config.project_root) }
134
+ config.sources.each { |f| require_project_file(config.project_root, f) }
98
135
  end
99
- config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
136
+ config.require_paths.each { |f| require_project_file(config.project_root, f) }
100
137
  preload_owners(config) if config.boot && config.strategy == "redefine"
101
138
 
102
139
  if config.boot
@@ -133,11 +170,11 @@ module Mutineer
133
170
  source_paths: config.sources, test_paths: config.tests,
134
171
  cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
135
172
  load_paths: config.load_paths + rel_roots, framework: config.framework,
136
- require_paths: config.require_paths,
173
+ require_paths: config.require_paths, verbose: config.verbose,
137
174
  capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
138
175
  ).build_or_load
139
176
  end
140
- JobPlan.abort_if_unclean!(coverage_map)
177
+ JobPlan.abort_if_unclean!(coverage_map, framework: config.framework)
141
178
 
142
179
  # Collect every (subject, mutation) up front so the pool can fan them out.
143
180
  jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
@@ -227,15 +264,23 @@ module Mutineer
227
264
  # 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
228
265
  # a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
229
266
  # handful cannot stall a serial run for ~45min on a slow suite.
230
- smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
267
+ smoke_elapsed, check_summary = ExternalBackend.smoke_check!(config.test_command, abs_tests)
231
268
  timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
269
+ # #292: the summary check needs a summary line to look for. Without one
270
+ # on the clean run, keep the exit-code rule and say what that costs.
271
+ unless check_summary
272
+ warn "[mutineer] the clean run of --test-command printed no Minitest or RSpec summary line " \
273
+ "(such as \"3 runs, 3 assertions, 0 failures, 0 errors\" or \"3 examples, 0 failures\"), " \
274
+ "so the crash check is off: an exit 1 counts as a kill even when the suite crashed " \
275
+ "before its tests ran. Use the default Minitest or RSpec progress output to turn it on."
276
+ end
232
277
 
233
278
  results = []
234
279
  progress = Progress.new(jobs.size)
235
280
  begin
236
281
  jobs.each do |subject, mutation, id|
237
282
  r = run_external(subject, mutation, config.test_command, abs_tests,
238
- timeout: timeout, verbose: config.verbose)
283
+ timeout: timeout, verbose: config.verbose, check_summary: check_summary)
239
284
  results << r.with(subject: subject, mutation: mutation, id: id)
240
285
  progress.tick
241
286
  break if config.fail_fast && r.survived? # stop at the first survivor
@@ -250,18 +295,19 @@ module Mutineer
250
295
 
251
296
  # Runs one mutant through the external backend: apply the whole-file mutation
252
297
  # on disk, run the command, restore. An invalid (non-reparsing) mutant would
253
- # fail to load and score a false `killed`, so skip it tool-side (Prism, already
254
- # cheap) and never write the file, preserving the `skipped` verdict the
298
+ # fail to load (scored `error`, or a false `killed` without the summary
299
+ # check), so skip it tool-side (Prism, already cheap) and never write the file, preserving the `skipped` verdict the
255
300
  # in-process path gives at the pre-fork check.
256
301
  #
302
+ # @param check_summary [Boolean] passed to {ExternalBackend.run}.
257
303
  # @return [Mutineer::Result] verdict for this mutant.
258
- def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:)
259
- source = File.read(subject.file)
304
+ def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:, check_summary:)
305
+ source = File.read(subject.file, encoding: "UTF-8")
260
306
  mutated = mutation.apply(source)
261
307
  return Result.skipped if Parser.parse_string(mutated).errors.any?
262
308
 
263
309
  FileSwap.with(subject.file, mutated) do
264
- ExternalBackend.run(command, abs_tests, timeout: timeout, verbose: verbose)
310
+ ExternalBackend.run(command, abs_tests, timeout: timeout, verbose: verbose, check_summary: check_summary)
265
311
  end
266
312
  end
267
313
 
@@ -304,7 +350,8 @@ module Mutineer
304
350
  # @param mutation [Mutineer::Mutation] mutation to run.
305
351
  # @param source_file [String] source file path.
306
352
  # @param coverage_map [Mutineer::CoverageMap, nil] coverage map.
307
- # @param subject [Mutineer::Subject, nil] subject for surgical strategy.
353
+ # @param subject [Mutineer::Subject, nil] subject for surgical strategy;
354
+ # redefine also loads each of its enclosing subjects.
308
355
  # @param strategy [String] mutation strategy.
309
356
  # @param timeout [Integer] child timeout in seconds.
310
357
  # @param rails [Boolean] whether Rails reconnect handling is enabled.
@@ -314,7 +361,7 @@ module Mutineer
314
361
  # @return [Mutineer::Result] mutant result.
315
362
  def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
316
363
  timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest", matrix: false)
317
- source = File.read(source_file)
364
+ source = File.read(source_file, encoding: "UTF-8")
318
365
  mutated = mutation.apply(source)
319
366
 
320
367
  # Validity rule: a mutant that does not re-parse is skipped before forking.
@@ -336,7 +383,16 @@ module Mutineer
336
383
  # across processes corrupts it. Drop it so AR reconnects per child.
337
384
  reconnect_active_record if rails
338
385
  if strategy == "redefine"
386
+ # Load each enclosing def too: its body holds the original nested def,
387
+ # which a call to it would define again over the mutant (#290).
339
388
  Isolation.apply_surgical(mutation, subject, source)
389
+ s = subject
390
+ Isolation.apply_surgical(mutation, s, source) while (s = s.enclosing_subject)
391
+ elsif subject&.reload_hook_call
392
+ # #297: a def in a concern's hook block reaches each class that has it.
393
+ code, hook_def = JobPlan.hook_mutant(mutated, mutation, subject, source)
394
+ Isolation.apply_whole_file(code, source_file)
395
+ ConcernBlocks.place_hook_def(source_file: source_file, **hook_def)
340
396
  else
341
397
  Isolation.apply_whole_file(mutated, source_file)
342
398
  end
@@ -415,6 +471,58 @@ module Mutineer
415
471
  end
416
472
  private_class_method :reconnect_active_record
417
473
 
474
+ # The absolute path of a project file `require` can load: as given, or
475
+ # with `.rb` or the native-extension suffix.
476
+ #
477
+ # @api private
478
+ # @param root [String] the project root.
479
+ # @param path [String] the path as the user gave it.
480
+ # @raise [Errno::ENOENT] when the requested file does not exist.
481
+ # @raise [ArgumentError] when Ruby cannot expand the path.
482
+ # @return [String]
483
+ def self.project_file!(root, path)
484
+ file = File.expand_path(path, root)
485
+ suffixes = ["", ".rb", ".#{RbConfig::CONFIG.fetch('DLEXT')}"]
486
+ raise Errno::ENOENT, path unless suffixes.any? { |suffix| File.file?("#{file}#{suffix}") }
487
+
488
+ file
489
+ end
490
+ private_class_method :project_file!
491
+
492
+ # Require one project file in the tool process. A failure becomes a
493
+ # {ProjectLoadError} whose message, and each cause in its list, is
494
+ # redacted. The original backtrace is kept; the cause is not, because
495
+ # Ruby would print its raw message.
496
+ #
497
+ # @api private
498
+ # @param root [String] the project root.
499
+ # @param path [String] the path as the user gave it.
500
+ # @raise [Errno::ENOENT] when the requested file does not exist.
501
+ # @raise [ProjectLoadError] when the file raises while it loads.
502
+ # @return [void]
503
+ def self.require_project_file(root, path)
504
+ # Outside the rescue: a path Ruby cannot expand (`~nosuchuser/boot.rb`)
505
+ # stays a usage error (exit 2), not a load failure.
506
+ file = project_file!(root, path)
507
+
508
+ begin
509
+ require file
510
+ rescue StandardError, ScriptError => e
511
+ # Every part is redacted on its own, so the message build cannot raise
512
+ # on mixed encodings and leave the raw error as the new one's cause.
513
+ causes = []
514
+ cause = e.cause
515
+ while cause
516
+ causes << Redaction.describe(cause)
517
+ cause = cause.cause
518
+ end
519
+ error = ProjectLoadError.new("cannot load #{Redaction.text(path)}: #{Redaction.describe(e)}", causes: causes)
520
+ error.set_backtrace(e.backtrace)
521
+ raise error, cause: nil
522
+ end
523
+ end
524
+ private_class_method :require_project_file
525
+
418
526
  # Pure, injectable predicate: true when a transactional-fixture transaction is
419
527
  # already open on the connection. Keys off open_transactions so it is correct
420
528
  # whenever the transaction exists, regardless of when it opened. Any probe
@@ -12,16 +12,20 @@ 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
+ # @param loose [Boolean] return the lines also when their tests may not
16
+ # have run the position: when it is in code that runs only sometimes
17
+ # when the statement runs (the right side of `a && b`, a branch, the body
18
+ # of `x if c`, #254), and with a first or last line that holds other code
19
+ # (the opener of a one-line block, #289). The `def` line is never
20
+ # given, because it counts when the file loads. So every test that ran
21
+ # the position is among the tests of these lines, unless the statement
22
+ # starts on the `def` line.
20
23
  # @return [Array<Integer>] the lines of the statement, with the body of each
21
24
  # heredoc in it. Empty when no statement in the body holds the position.
22
- # A first or last line that holds other code is left out, because Ruby
23
- # can count that line without the statement: the `def` line counts when
24
- # the file loads, and `x if done; y(` counts when `y` does not run.
25
+ # Unless `loose`, a first or last line that holds other code is left out,
26
+ # because Ruby can count that line without the statement: the `def` line
27
+ # counts when the file loads, and `x if done; y(` counts when `y` does
28
+ # not run.
25
29
  def self.for(def_node, source, offset, loose: false)
26
30
  return [] unless def_node.body
27
31
 
@@ -42,6 +46,8 @@ module Mutineer
42
46
 
43
47
  location = statement.location
44
48
  lines = (location.start_line..last_line(statement)).to_a
49
+ return lines - [def_node.location.start_line] if loose
50
+
45
51
  lines.delete(location.start_line) unless before(source, location).strip.empty?
46
52
  after = after(source, location).strip
47
53
  lines.delete(location.end_line) unless after.empty? || after.start_with?("#")