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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +432 -0
- data/README.md +157 -747
- data/lib/mutineer/baseline.rb +1 -1
- data/lib/mutineer/changed_lines.rb +2 -1
- data/lib/mutineer/cli.rb +119 -27
- data/lib/mutineer/concern_blocks.rb +168 -5
- data/lib/mutineer/config.rb +6 -0
- data/lib/mutineer/coverage_map.rb +92 -17
- data/lib/mutineer/daemon_backend.rb +75 -32
- data/lib/mutineer/daemon_client.rb +50 -17
- data/lib/mutineer/daemon_server.rb +92 -40
- data/lib/mutineer/external_backend.rb +150 -17
- data/lib/mutineer/isolation.rb +67 -25
- data/lib/mutineer/job_plan.rb +117 -33
- data/lib/mutineer/kill_channel.rb +24 -14
- data/lib/mutineer/migrate.rb +10 -8
- data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
- data/lib/mutineer/minitest_integration.rb +44 -14
- data/lib/mutineer/mutators/array_literal.rb +4 -2
- data/lib/mutineer/mutators/base.rb +22 -0
- data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
- data/lib/mutineer/mutators/chain_link.rb +6 -6
- data/lib/mutineer/mutators/condition_forcing.rb +6 -3
- data/lib/mutineer/mutators/operand_removal.rb +4 -2
- data/lib/mutineer/mutators/range_literal.rb +105 -4
- data/lib/mutineer/mutators/return_nil.rb +1 -0
- data/lib/mutineer/orphan_guard.rb +18 -6
- data/lib/mutineer/pairing.rb +62 -1
- data/lib/mutineer/project.rb +226 -12
- data/lib/mutineer/rails_worker_db.rb +200 -16
- data/lib/mutineer/redaction.rb +112 -11
- data/lib/mutineer/reporter.rb +109 -21
- data/lib/mutineer/result.rb +33 -5
- data/lib/mutineer/runner.rb +126 -18
- data/lib/mutineer/statement_lines.rb +14 -8
- data/lib/mutineer/subject.rb +13 -5
- data/lib/mutineer/test_runners/minitest.rb +2 -1
- data/lib/mutineer/test_runners.rb +4 -2
- data/lib/mutineer/triage.rb +1 -1
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +2 -1
- data/skills/mutineer/SKILL.md +84 -0
- metadata +5 -1
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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.
|
|
828
|
-
#
|
|
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
|
|
955
|
+
.sort_by { |r| [r.subject.file, *tie_key(r)] }
|
|
891
956
|
.each do |r|
|
|
892
957
|
file = r.subject.file
|
|
893
|
-
source =
|
|
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
|
|
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 =
|
|
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}%"
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -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)
|
|
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)
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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,
|
|
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.
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
64
|
-
#
|
|
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
|
-
|
|
132
|
+
require_project_file(config.project_root, config.boot)
|
|
96
133
|
else
|
|
97
|
-
config.sources.each { |f|
|
|
134
|
+
config.sources.each { |f| require_project_file(config.project_root, f) }
|
|
98
135
|
end
|
|
99
|
-
config.require_paths.each { |f|
|
|
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
|
|
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]
|
|
16
|
-
#
|
|
17
|
-
# `a && b`, a branch, the body
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
-
#
|
|
23
|
-
# can count that line without the statement: the `def` line
|
|
24
|
-
# the file loads, and `x if done; y(` counts when `y` does
|
|
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?("#")
|