mutineer-mongoid 2.0.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 +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- metadata +143 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "set"
|
|
5
|
+
require_relative "parser"
|
|
6
|
+
require_relative "project"
|
|
7
|
+
require_relative "result"
|
|
8
|
+
require_relative "statement_lines"
|
|
9
|
+
require_relative "changed_lines"
|
|
10
|
+
require_relative "mutator_registry"
|
|
11
|
+
require_relative "mutant_id"
|
|
12
|
+
require_relative "project_path"
|
|
13
|
+
require_relative "external_backend"
|
|
14
|
+
|
|
15
|
+
module Mutineer
|
|
16
|
+
# The job vocabulary every backend shares: which mutants run, which are
|
|
17
|
+
# ignored, which lines `--since` keeps, which tests cover a mutant, and the
|
|
18
|
+
# source and test directories a run touches. {Runner}, {DaemonBackend} and
|
|
19
|
+
# {CLI} each require this file, so job selection cannot drift between them
|
|
20
|
+
# (score parity), and the require graph has no cycles (#75). Never require
|
|
21
|
+
# runner.rb, daemon_backend.rb or cli.rb from here: each requires this file.
|
|
22
|
+
module JobPlan
|
|
23
|
+
# Collect every (subject, mutation, id) up front so a backend can run them.
|
|
24
|
+
# A mutant the user marked known-equivalent (inline disable-line comment or
|
|
25
|
+
# .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
|
|
26
|
+
# removed from the killed+survived denominator so a strong file reaches 100%.
|
|
27
|
+
# The id is computed per subject (occurrence needs the full list), keyed on
|
|
28
|
+
# the file path relative to config.project_root, and carried on every job so
|
|
29
|
+
# the parent can reattach it after the run. Shared by the in-process,
|
|
30
|
+
# external, and daemon backends so job selection can never drift.
|
|
31
|
+
#
|
|
32
|
+
# Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
|
|
33
|
+
# entry stored before ids carried the path still suppresses it. Prints nothing:
|
|
34
|
+
# the extras hash returns, as data, `legacy_ignore_matches` (each old-format
|
|
35
|
+
# ignore entry that matched a mutant through its old-format id => one
|
|
36
|
+
# `{id:, file:, subject:}` hash per matched mutant, in collection order: its
|
|
37
|
+
# new id, its project-relative file and its subject's qualified name;
|
|
38
|
+
# recorded even when a new id is also listed, since the old entry still
|
|
39
|
+
# over-matches other files) and `id_map` (every new id => its old-format id).
|
|
40
|
+
#
|
|
41
|
+
# Subjects sharing a qualified name in one file (two owner-less `def index`
|
|
42
|
+
# in two DSL blocks) get a per-file ordinal in discovery order, so their ids
|
|
43
|
+
# differ; the first one's ordinal is 0 and leaves its id unchanged.
|
|
44
|
+
#
|
|
45
|
+
# @param config [Mutineer::Config] run configuration.
|
|
46
|
+
# @param operator_classes [Array<Class>] resolved operators.
|
|
47
|
+
# @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
|
|
48
|
+
# jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
|
|
49
|
+
def self.collect_jobs(config, operator_classes)
|
|
50
|
+
source_map = {}
|
|
51
|
+
disabled_map = {}
|
|
52
|
+
id_paths = {}
|
|
53
|
+
# [file, qualified_name] => { declaration offset => ordinal }: keyed by the
|
|
54
|
+
# declaration, so the same file discovered twice (two path spellings) reuses
|
|
55
|
+
# its ordinal instead of minting a second id for the same mutant.
|
|
56
|
+
name_decls = Hash.new { |h, k| h[k] = {} }
|
|
57
|
+
ignore_set = config.ignore.to_set
|
|
58
|
+
jobs = []
|
|
59
|
+
ignored_results = []
|
|
60
|
+
legacy_ignore_matches = {}
|
|
61
|
+
id_map = {}
|
|
62
|
+
Project.discover(config.sources, only: config.only).each do |subject|
|
|
63
|
+
source = (source_map[subject.file] ||= File.read(subject.file))
|
|
64
|
+
disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
|
|
65
|
+
mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
|
|
66
|
+
id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
|
|
67
|
+
decls = name_decls[[id_path, subject.qualified_name]]
|
|
68
|
+
ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
|
|
69
|
+
ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
|
|
70
|
+
legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
|
|
71
|
+
lines = mutations.map { |m| source.byteslice(0, m.start_offset).count("\n") + 1 }
|
|
72
|
+
keys = result_keys(mutations, source, lines)
|
|
73
|
+
# A repeat of an earlier edit on the same line is dropped (#159),
|
|
74
|
+
# separately among the run and the ignored mutants, so an ignored copy
|
|
75
|
+
# never hides a copy that should run. A dropped copy records nothing.
|
|
76
|
+
seen = { run: Set.new, ignored: Set.new }
|
|
77
|
+
mutations.each_with_index do |mutation, i|
|
|
78
|
+
id = ids[i]
|
|
79
|
+
legacy = legacy_ids[i]
|
|
80
|
+
ignored = suppressed?(mutation.operator, lines[i], [id, legacy], disabled, ignore_set)
|
|
81
|
+
next unless seen[ignored ? :ignored : :run].add?(keys[i])
|
|
82
|
+
|
|
83
|
+
id_map[id] = legacy
|
|
84
|
+
# An old entry still over-matches other files even when the new id is
|
|
85
|
+
# listed too, so every old-entry match is reported for migration.
|
|
86
|
+
if ignore_set.include?(legacy)
|
|
87
|
+
(legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
|
|
88
|
+
end
|
|
89
|
+
if ignored
|
|
90
|
+
ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
|
|
91
|
+
else
|
|
92
|
+
jobs << [subject, mutation, id]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
[jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# One key per mutation; two mutations share a key exactly when they are
|
|
100
|
+
# the same operator on the same line and give the same mutated source (one
|
|
101
|
+
# edit, #159). The caller drops a repeat after ids are assigned. Only a
|
|
102
|
+
# group of same-operator, same-line mutations can repeat, so only those
|
|
103
|
+
# build a key from their text: the span from the group's earliest start to
|
|
104
|
+
# its latest end, digested. Every other mutation gets a key of its own.
|
|
105
|
+
#
|
|
106
|
+
# @param mutations [Array<Mutineer::Mutation>] one subject's mutations.
|
|
107
|
+
# @param source [String] the full, unmutated source.
|
|
108
|
+
# @param lines [Array<Integer>] each mutation's line.
|
|
109
|
+
# @return [Array<Object>] one key per mutation, in order.
|
|
110
|
+
def self.result_keys(mutations, source, lines)
|
|
111
|
+
keys = Array.new(mutations.size) { |i| i }
|
|
112
|
+
mutations.each_index.group_by { |i| [mutations[i].operator, lines[i]] }.each_value do |group|
|
|
113
|
+
next if group.size < 2
|
|
114
|
+
|
|
115
|
+
from = group.map { |i| mutations[i].start_offset }.min
|
|
116
|
+
to = group.map { |i| mutations[i].end_offset }.max
|
|
117
|
+
group.each do |i|
|
|
118
|
+
m = mutations[i]
|
|
119
|
+
span = "#{source.byteslice(from...m.start_offset)}#{m.replacement}#{source.byteslice(m.end_offset...to)}"
|
|
120
|
+
keys[i] = [m.operator, lines[i], Digest::SHA256.digest(span)]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
keys
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Aborts the run when coverage capture saw a red unmutated suite. Scoring
|
|
127
|
+
# those results would treat existing assertion failures as killed mutants.
|
|
128
|
+
#
|
|
129
|
+
# @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
|
|
130
|
+
# @return [void]
|
|
131
|
+
# @raise [Mutineer::SmokeCheckError] when any captured test failed clean,
|
|
132
|
+
# or when no test recorded coverage and a capture failed.
|
|
133
|
+
def self.abort_if_unclean!(coverage_map)
|
|
134
|
+
if coverage_map.map.empty? && coverage_map.failed_test_files.any?
|
|
135
|
+
raise SmokeCheckError, "no test recorded coverage, and capture failed for #{coverage_map.failed_test_files.join(', ')}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
files = coverage_map.failed_clean_tests
|
|
139
|
+
return if files.empty?
|
|
140
|
+
|
|
141
|
+
raise SmokeCheckError,
|
|
142
|
+
"the unmutated suite is not green (#{files.join(', ')}) — " \
|
|
143
|
+
"#{ExternalBackend.generic_env_hint}."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Coverage-based test selection, shared by the in-process ({Runner.run}) and daemon
|
|
147
|
+
# paths so both narrow identically (score parity). Returns
|
|
148
|
+
# `[:run, abs_test_paths]` when some test covers the mutant's line, in the
|
|
149
|
+
# order of {CoverageMap#order_tests} (paired files, then cheapest), or
|
|
150
|
+
# `[:verdict, Result]` (no_coverage / uncapturable) when none do. A line
|
|
151
|
+
# Ruby does not count (a later line of a multi-line statement) uses the
|
|
152
|
+
# tests that ran the statement that holds it ({StatementLines}), unless the
|
|
153
|
+
# mutant sits in code of that statement that runs only sometimes.
|
|
154
|
+
#
|
|
155
|
+
# An empty selection is `:ran_at_load` when the mutant's line ran while the
|
|
156
|
+
# app booted or its class loaded ({ran_at_load?}): no test ran it, but the
|
|
157
|
+
# load did, so it is not a coverage gap.
|
|
158
|
+
#
|
|
159
|
+
# An empty selection is `:uncapturable` (not `:no_coverage`) when the
|
|
160
|
+
# mutant's enclosing method body got coverage from no *successful* capture but
|
|
161
|
+
# a sibling test failed to capture: the coverage was lost, not absent. Both are
|
|
162
|
+
# excluded from the score denominator, so this distinction is reporting-only
|
|
163
|
+
# and never changes the daemon-vs-in-process score.
|
|
164
|
+
#
|
|
165
|
+
# @param source_file [String] the mutated source file path.
|
|
166
|
+
# @param mutation [Mutineer::Mutation] the mutation (for its line offset).
|
|
167
|
+
# @param subject [Mutineer::Subject, nil] the subject (for its method body range).
|
|
168
|
+
# @param source [String] the original source text.
|
|
169
|
+
# @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
|
|
170
|
+
# @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
|
|
171
|
+
def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
172
|
+
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
173
|
+
chosen = coverage_map.tests_for(source_file, line)
|
|
174
|
+
if chosen.empty? && subject
|
|
175
|
+
# A multi-line statement has a count on one of its lines only.
|
|
176
|
+
lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
|
|
177
|
+
chosen = lines.flat_map { |l| coverage_map.tests_for(source_file, l) }.uniq
|
|
178
|
+
end
|
|
179
|
+
if chosen.empty?
|
|
180
|
+
# Method BODY range, not the whole def: the def/end lines are "covered" at
|
|
181
|
+
# class-load even when the body never runs (body_loc is the statements' span).
|
|
182
|
+
loc = subject&.body_loc
|
|
183
|
+
range = loc ? (loc.start_line..loc.end_line) : (line..line)
|
|
184
|
+
return [:verdict, Result.uncapturable] if coverage_map.method_uncapturable?(source_file, range)
|
|
185
|
+
return [:verdict, Result.ran_at_load] if ran_at_load?(source_file, mutation, subject, source, coverage_map)
|
|
186
|
+
|
|
187
|
+
return [:verdict, Result.no_coverage]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
[:run, coverage_map.order_tests(source_file, chosen).map { |t| File.expand_path(t, coverage_map.project_root) }]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# True when the mutant's line ran while the app booted or its class loaded,
|
|
194
|
+
# so a test can check a value the original code computed before the mutant
|
|
195
|
+
# was applied. Shared by {coverage_selection}, {Runner.run} and the daemon backend.
|
|
196
|
+
#
|
|
197
|
+
# Only the lines of the statement that holds the mutant count
|
|
198
|
+
# ({StatementLines}), and only inside the method body. So code that runs
|
|
199
|
+
# only sometimes (`x if c`, a ternary branch) never counts: its line can
|
|
200
|
+
# count at load without it. The `def` line never counts: Ruby counts it
|
|
201
|
+
# when the method is defined.
|
|
202
|
+
#
|
|
203
|
+
# A one-line or endless def keeps its body on the `def` line, so it uses
|
|
204
|
+
# the method's own call count at load instead (#209), for code that runs
|
|
205
|
+
# each time the method does ({StatementLines.runs_with_method?}).
|
|
206
|
+
#
|
|
207
|
+
# @param source_file [String] the mutated source file path.
|
|
208
|
+
# @param mutation [Mutineer::Mutation] the mutation.
|
|
209
|
+
# @param subject [Mutineer::Subject, nil] the subject (for its method body range).
|
|
210
|
+
# @param source [String] the original source text.
|
|
211
|
+
# @param coverage_map [Mutineer::CoverageMap, nil] the coverage map.
|
|
212
|
+
# @return [Boolean]
|
|
213
|
+
def self.ran_at_load?(source_file, mutation, subject, source, coverage_map)
|
|
214
|
+
loc = subject&.body_loc
|
|
215
|
+
return false unless loc && coverage_map
|
|
216
|
+
|
|
217
|
+
def_loc = subject.def_node.location
|
|
218
|
+
def_line = def_loc.start_line
|
|
219
|
+
if loc.end_line == def_line
|
|
220
|
+
return coverage_map.method_ran_at_load?(source_file, def_line, def_loc.start_column) &&
|
|
221
|
+
StatementLines.runs_with_method?(subject.def_node, mutation.start_offset)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
body = (loc.start_line..loc.end_line)
|
|
225
|
+
lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
|
|
226
|
+
lines.any? { |l| l != def_line && body.cover?(l) && coverage_map.ran_at_load?(source_file, l) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# A survivor whose line ran at load becomes `ran_at_load` ({ran_at_load?});
|
|
230
|
+
# any other result comes back unchanged. A `--matrix` row is kept.
|
|
231
|
+
#
|
|
232
|
+
# @param result [Mutineer::Result] the mutant's verdict.
|
|
233
|
+
# @param source_file [String] the mutated source file path.
|
|
234
|
+
# @param mutation [Mutineer::Mutation] the mutation.
|
|
235
|
+
# @param subject [Mutineer::Subject, nil] the subject.
|
|
236
|
+
# @param source [String] the original source text.
|
|
237
|
+
# @param coverage_map [Mutineer::CoverageMap, nil] the coverage map.
|
|
238
|
+
# @return [Mutineer::Result]
|
|
239
|
+
def self.load_verdict(result, source_file, mutation, subject, source, coverage_map)
|
|
240
|
+
return result unless result.survived? && ran_at_load?(source_file, mutation, subject, source, coverage_map)
|
|
241
|
+
|
|
242
|
+
result.with(status: :ran_at_load)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Map each line number to :all or a set of operator symbols, using
|
|
246
|
+
# inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
|
|
247
|
+
# sits on the same physical line as the code it silences). A bare marker
|
|
248
|
+
# disables every operator on that line; `disable-line a, b` only the listed
|
|
249
|
+
# operators. Block-form disable/enable ranges are intentionally not supported.
|
|
250
|
+
# Only a real `#` comment counts: Prism lists the comments, so the marker
|
|
251
|
+
# text inside a string, heredoc or regex silences nothing (#158).
|
|
252
|
+
def self.suppress_map(source, file)
|
|
253
|
+
map = {}
|
|
254
|
+
Parser.comments(source).grep(Prism::InlineComment).each do |comment|
|
|
255
|
+
line = comment.location.start_line
|
|
256
|
+
next unless (m = comment.slice.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
|
|
257
|
+
|
|
258
|
+
ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
|
|
259
|
+
# Only spaces or commas after the marker (e.g. `disable-line -- why`)
|
|
260
|
+
# is a bare marker, not an empty list that silences nothing.
|
|
261
|
+
ops = nil if ops&.empty?
|
|
262
|
+
unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
|
|
263
|
+
unknown.each do |o|
|
|
264
|
+
warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
|
|
265
|
+
"(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
|
|
266
|
+
end
|
|
267
|
+
map[line] = ops ? ops.map(&:to_sym).to_set : :all
|
|
268
|
+
end
|
|
269
|
+
map
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# True when this mutant is suppressed: its line bears a disable-line marker
|
|
273
|
+
# (bare, or scoped to its operator), OR its new or old-format id is in the
|
|
274
|
+
# config ignore list. Checked at job-build time so a suppressed mutant is
|
|
275
|
+
# never forked.
|
|
276
|
+
#
|
|
277
|
+
# @param ids [Array<String>, String] the mutant's new id and its old-format
|
|
278
|
+
# id, or a single id (the pre-#126 call shape).
|
|
279
|
+
def self.suppressed?(operator, line, ids, disabled, ignore_set)
|
|
280
|
+
return true if Array(ids).any? { |id| ignore_set.include?(id) }
|
|
281
|
+
|
|
282
|
+
case (entry = disabled[line])
|
|
283
|
+
when :all then true
|
|
284
|
+
when Set then entry.include?(operator)
|
|
285
|
+
else false
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# --since: keep only jobs whose mutation lands on a line changed since the git
|
|
290
|
+
# ref. Composes with coverage selection (it only narrows the job list; each
|
|
291
|
+
# surviving mutant still goes through Runner.run's coverage check). A file with
|
|
292
|
+
# no changed lines (absent from the diff) contributes no jobs. Line is computed
|
|
293
|
+
# exactly as Runner.run does, from the already-read source in source_map.
|
|
294
|
+
def self.filter_since(jobs, source_map, config)
|
|
295
|
+
changed = ChangedLines.for(ref: config.since, files: config.sources,
|
|
296
|
+
project_root: config.project_root)
|
|
297
|
+
jobs.select do |subject, mutation|
|
|
298
|
+
source = source_map[subject.file]
|
|
299
|
+
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
300
|
+
abs = File.expand_path(subject.file, config.project_root)
|
|
301
|
+
changed.fetch(abs, []).include?(line)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# For each test file, the directory to add to $LOAD_PATH so its
|
|
306
|
+
# `require "test_helper"` (or spec_helper) resolves: the nearest ancestor
|
|
307
|
+
# holding that helper, plus the file's own dir as a fallback.
|
|
308
|
+
def self.test_load_roots(test_files)
|
|
309
|
+
test_files.flat_map do |f|
|
|
310
|
+
dir = File.dirname(f)
|
|
311
|
+
root = nil
|
|
312
|
+
loop do
|
|
313
|
+
if File.exist?(File.join(dir, "test_helper.rb")) || File.exist?(File.join(dir, "spec_helper.rb"))
|
|
314
|
+
root = dir
|
|
315
|
+
break
|
|
316
|
+
end
|
|
317
|
+
parent = File.dirname(dir)
|
|
318
|
+
break if parent == dir
|
|
319
|
+
|
|
320
|
+
dir = parent
|
|
321
|
+
end
|
|
322
|
+
[root, File.dirname(f)].compact
|
|
323
|
+
end.uniq
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# The unique absolute directories holding the sources. Sweep target for both
|
|
327
|
+
# orphan mechanisms (in-process mutant tempfiles and external backup files),
|
|
328
|
+
# and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
|
|
329
|
+
# Shared so the path-expansion rule cannot drift between the paths.
|
|
330
|
+
#
|
|
331
|
+
# @param config [Mutineer::Config] run configuration.
|
|
332
|
+
# @return [Array<String>] unique absolute source directories.
|
|
333
|
+
def self.source_dirs(config)
|
|
334
|
+
config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Removes stale mutant tempfiles from the given directories. The daemon writes a
|
|
338
|
+
# differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
|
|
339
|
+
# tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
|
|
340
|
+
#
|
|
341
|
+
# @param dirs [Array<String>] directories to sweep.
|
|
342
|
+
# @param glob [String] filename pattern to remove.
|
|
343
|
+
# @return [void]
|
|
344
|
+
def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
|
|
345
|
+
dirs.each do |dir|
|
|
346
|
+
Dir.glob(File.join(dir, glob)).each do |f|
|
|
347
|
+
File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
# The line protocol a `--matrix` mutant child uses to tell its parent how its
|
|
7
|
+
# tests went. One JSON array per line:
|
|
8
|
+
#
|
|
9
|
+
# ["start"] the recorder is armed; once, first
|
|
10
|
+
# ["pass", file, name, id] a test passed against the mutant
|
|
11
|
+
# ["kill", file, name, id] a test failed or errored: it killed it
|
|
12
|
+
# ["parallel"] the run reached its parallel tests; once
|
|
13
|
+
# ["skip"] what runs next, a run without --matrix
|
|
14
|
+
# skips (see below)
|
|
15
|
+
# ["unskip"] the matching end of a ["skip"]
|
|
16
|
+
# ["cleanup"] every test ran; suite hooks follow; once
|
|
17
|
+
# ["lost"] a test the recorder could not describe
|
|
18
|
+
# ["end"] the suite returned with every test seen;
|
|
19
|
+
# once, last
|
|
20
|
+
#
|
|
21
|
+
# `id` tells two tests apart when `[file, name]` does not, and it is what
|
|
22
|
+
# identifies the test across mutants (an RSpec example id such as
|
|
23
|
+
# `./spec/calc_spec.rb[1:2]`; the name can change with the mutant). For
|
|
24
|
+
# Minitest it equals the name. A test line is written when the test is
|
|
25
|
+
# recorded, so a child killed at the timeout has already sent every kill it
|
|
26
|
+
# saw. A skipped test sends nothing.
|
|
27
|
+
#
|
|
28
|
+
# Minitest runs its serial test classes first and its `parallelize_me!`
|
|
29
|
+
# classes after them, and the stop at the first failure of a run without
|
|
30
|
+
# `--matrix` skips everything after a serial kill. `parallel` marks where the
|
|
31
|
+
# parallel tests begin, so the parent can tell a kill that the stop would have
|
|
32
|
+
# followed from one it could not. `cleanup` marks where the tests end and the
|
|
33
|
+
# suite's own hooks begin (RSpec `after(:suite)`). It decides no verdict
|
|
34
|
+
# (the `skip` regions do); it only marks a later test line as out of order.
|
|
35
|
+
#
|
|
36
|
+
# After the first serial kill, a run without `--matrix` skips every later
|
|
37
|
+
# test and every later test class or example group, but it still runs the
|
|
38
|
+
# rest of the code around the failing test: the remainder of its Minitest
|
|
39
|
+
# class `run` wrapper, the `after(:all)` hooks of its RSpec groups, and the
|
|
40
|
+
# suite hooks. The recorder writes `skip` as it enters something that run
|
|
41
|
+
# would skip and `unskip` as that returns, so a child that exits, crashes or
|
|
42
|
+
# hangs inside a skipped region is one the plain run would have stopped
|
|
43
|
+
# before (see {Isolation.finish}). The pairs nest.
|
|
44
|
+
#
|
|
45
|
+
# A row is complete only with `start` and `end`, in order, and no lost line: a
|
|
46
|
+
# test that exits the process, a crash, an interrupted run or a recorder that
|
|
47
|
+
# never armed leaves one out. A stream out of order (a duplicate marker, a
|
|
48
|
+
# test line before `start` or after `end`) is invalid, and the parent trusts
|
|
49
|
+
# nothing from it.
|
|
50
|
+
#
|
|
51
|
+
# Writing never raises: a recorder that broke the test run would change the
|
|
52
|
+
# verdict, and the exit status decides the verdict. A lost line leaves the row
|
|
53
|
+
# incomplete. After one write to a channel fails, nothing more is written to
|
|
54
|
+
# it, so a later `end` can never make a row with a missing line look complete.
|
|
55
|
+
#
|
|
56
|
+
# Stdlib-only, so the app-side daemon can load the files that require it.
|
|
57
|
+
module KillChannel
|
|
58
|
+
# Event for a test that passed against the mutant.
|
|
59
|
+
PASS = "pass"
|
|
60
|
+
|
|
61
|
+
# Event for a test that failed or errored against the mutant: it killed it.
|
|
62
|
+
KILL = "kill"
|
|
63
|
+
|
|
64
|
+
# Event that opens a recorded run.
|
|
65
|
+
START = "start"
|
|
66
|
+
|
|
67
|
+
# Event that marks the first parallel test of a run.
|
|
68
|
+
PARALLEL = "parallel"
|
|
69
|
+
|
|
70
|
+
# Event that marks the end of the tests and the start of the suite's cleanup.
|
|
71
|
+
CLEANUP = "cleanup"
|
|
72
|
+
|
|
73
|
+
# Event that opens a region a run without `--matrix` would skip.
|
|
74
|
+
SKIP = "skip"
|
|
75
|
+
|
|
76
|
+
# Event that closes the latest open {SKIP} region.
|
|
77
|
+
UNSKIP = "unskip"
|
|
78
|
+
|
|
79
|
+
# Event for a test the recorder could not describe.
|
|
80
|
+
LOST = "lost"
|
|
81
|
+
|
|
82
|
+
# Event that closes a run whose suite returned with every test seen.
|
|
83
|
+
FINISH = "end"
|
|
84
|
+
|
|
85
|
+
# Serializes writes from tests that record on several threads.
|
|
86
|
+
LOCK = Mutex.new
|
|
87
|
+
|
|
88
|
+
# What one child sent. `killed` and `ran` are sorted, unique
|
|
89
|
+
# `[file, name, id]` tests (`ran` includes the killers); `lost` counts lines
|
|
90
|
+
# that could not be read, including a partial last line. `parallel` and
|
|
91
|
+
# `cleanup` say the marker arrived, and `serial_kill` that a test killed
|
|
92
|
+
# before the `parallel` marker. `skipping` counts the {SKIP} regions still
|
|
93
|
+
# open when the stream ended. `invalid` is set by any line out of order.
|
|
94
|
+
Report = Struct.new(:killed, :ran, :started, :finished, :parallel, :cleanup, :serial_kill, :skipping, :invalid,
|
|
95
|
+
:lost, keyword_init: true)
|
|
96
|
+
|
|
97
|
+
# Writes one test line.
|
|
98
|
+
#
|
|
99
|
+
# @param io [IO] the write end of the channel.
|
|
100
|
+
# @param event [String] {PASS} or {KILL}.
|
|
101
|
+
# @param file [String] the file that defines the test.
|
|
102
|
+
# @param name [String] the test's display name, e.g. `CalculatorTest#test_add`.
|
|
103
|
+
# @param id [String] what tells the test apart from others with its file and
|
|
104
|
+
# name; defaults to the name.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def self.write(io, event, file, name, id = name)
|
|
107
|
+
emit(io, [event, utf8(file), utf8(name), utf8(id)])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Writes the `start` line.
|
|
111
|
+
#
|
|
112
|
+
# @param io [IO] the write end of the channel.
|
|
113
|
+
# @return [void]
|
|
114
|
+
def self.write_start(io)
|
|
115
|
+
emit(io, [START])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Writes the `parallel` line.
|
|
119
|
+
#
|
|
120
|
+
# @param io [IO] the write end of the channel.
|
|
121
|
+
# @return [void]
|
|
122
|
+
def self.write_parallel(io)
|
|
123
|
+
emit(io, [PARALLEL])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Writes the `skip` line.
|
|
127
|
+
#
|
|
128
|
+
# @param io [IO] the write end of the channel.
|
|
129
|
+
# @return [void]
|
|
130
|
+
def self.write_skip(io)
|
|
131
|
+
emit(io, [SKIP])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Writes the `unskip` line.
|
|
135
|
+
#
|
|
136
|
+
# @param io [IO] the write end of the channel.
|
|
137
|
+
# @return [void]
|
|
138
|
+
def self.write_unskip(io)
|
|
139
|
+
emit(io, [UNSKIP])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Writes the `cleanup` line.
|
|
143
|
+
#
|
|
144
|
+
# @param io [IO] the write end of the channel.
|
|
145
|
+
# @return [void]
|
|
146
|
+
def self.write_cleanup(io)
|
|
147
|
+
emit(io, [CLEANUP])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Writes the `lost` line, for a test the recorder could not describe.
|
|
151
|
+
#
|
|
152
|
+
# @param io [IO] the write end of the channel.
|
|
153
|
+
# @return [void]
|
|
154
|
+
def self.write_lost(io)
|
|
155
|
+
emit(io, [LOST])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Writes the `end` line.
|
|
159
|
+
#
|
|
160
|
+
# @param io [IO] the write end of the channel.
|
|
161
|
+
# @return [void]
|
|
162
|
+
def self.write_end(io)
|
|
163
|
+
emit(io, [FINISH])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Reads every complete line of `buffer`.
|
|
167
|
+
#
|
|
168
|
+
# @param buffer [String] bytes read from the channel.
|
|
169
|
+
# @return [Report]
|
|
170
|
+
def self.parse(buffer)
|
|
171
|
+
report = Report.new(killed: [], ran: [], started: false, finished: false, parallel: false, cleanup: false,
|
|
172
|
+
serial_kill: false, skipping: 0, invalid: false, lost: 0)
|
|
173
|
+
buffer.dup.force_encoding(Encoding::UTF_8).each_line do |line|
|
|
174
|
+
fields = line.end_with?("\n") ? parse_line(line) : nil
|
|
175
|
+
fields ? apply(report, fields) : report.lost += 1
|
|
176
|
+
end
|
|
177
|
+
report.killed = report.killed.uniq.sort
|
|
178
|
+
report.ran = report.ran.uniq.sort
|
|
179
|
+
report
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Adds one parsed line to `report`. A line out of order marks the report
|
|
183
|
+
# invalid; the line still counts, since what it names happened.
|
|
184
|
+
#
|
|
185
|
+
# @api private
|
|
186
|
+
# @param report [Report] the report being built.
|
|
187
|
+
# @param fields [Array<String>] a valid line's fields.
|
|
188
|
+
# @return [void]
|
|
189
|
+
def self.apply(report, fields)
|
|
190
|
+
case fields[0]
|
|
191
|
+
when START
|
|
192
|
+
report.invalid = true if report.started || report.ran.any? || report.parallel || report.cleanup || report.finished
|
|
193
|
+
report.started = true
|
|
194
|
+
when PARALLEL
|
|
195
|
+
report.invalid = true unless open?(report) && !report.parallel && !report.cleanup
|
|
196
|
+
report.parallel = true
|
|
197
|
+
when SKIP
|
|
198
|
+
report.invalid = true unless open?(report) && !report.cleanup
|
|
199
|
+
report.skipping += 1
|
|
200
|
+
when UNSKIP
|
|
201
|
+
report.invalid = true unless open?(report) && !report.cleanup && report.skipping.positive?
|
|
202
|
+
report.skipping -= 1 if report.skipping.positive?
|
|
203
|
+
when CLEANUP
|
|
204
|
+
report.invalid = true unless open?(report) && !report.cleanup
|
|
205
|
+
report.cleanup = true
|
|
206
|
+
when LOST then report.lost += 1
|
|
207
|
+
when FINISH
|
|
208
|
+
report.invalid = true unless open?(report) && !report.finished
|
|
209
|
+
report.finished = true
|
|
210
|
+
else
|
|
211
|
+
report.invalid = true unless open?(report) && !report.cleanup
|
|
212
|
+
test = fields[1, 3]
|
|
213
|
+
report.ran << test
|
|
214
|
+
return unless fields[0] == KILL
|
|
215
|
+
|
|
216
|
+
report.killed << test
|
|
217
|
+
report.serial_kill = true unless report.parallel
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# True between `start` and `end`.
|
|
222
|
+
#
|
|
223
|
+
# @api private
|
|
224
|
+
# @param report [Report] the report being built.
|
|
225
|
+
# @return [Boolean]
|
|
226
|
+
def self.open?(report)
|
|
227
|
+
report.started && !report.finished
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Parses one line into its fields, or nil when it is not a valid line.
|
|
231
|
+
#
|
|
232
|
+
# @api private
|
|
233
|
+
# @param line [String] one newline-terminated line.
|
|
234
|
+
# @return [Array<String>, nil]
|
|
235
|
+
def self.parse_line(line)
|
|
236
|
+
fields = JSON.parse(line)
|
|
237
|
+
return unless fields.is_a?(Array) && fields.all?(String)
|
|
238
|
+
|
|
239
|
+
valid =
|
|
240
|
+
case fields[0]
|
|
241
|
+
when PASS, KILL then fields.size == 4
|
|
242
|
+
when START, PARALLEL, SKIP, UNSKIP, CLEANUP, LOST, FINISH then fields.size == 1
|
|
243
|
+
end
|
|
244
|
+
fields if valid
|
|
245
|
+
rescue JSON::ParserError
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Writes one line. Never raises. After a write to `io` fails, writes
|
|
250
|
+
# nothing more to it.
|
|
251
|
+
#
|
|
252
|
+
# @api private
|
|
253
|
+
# @param io [IO] the write end of the channel.
|
|
254
|
+
# @param fields [Array<String>] the line's fields.
|
|
255
|
+
# @return [void]
|
|
256
|
+
def self.emit(io, fields)
|
|
257
|
+
line = "#{JSON.generate(fields)}\n"
|
|
258
|
+
LOCK.synchronize do
|
|
259
|
+
next if @broken.equal?(io)
|
|
260
|
+
|
|
261
|
+
begin
|
|
262
|
+
io.write(line)
|
|
263
|
+
rescue StandardError
|
|
264
|
+
@broken = io
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
nil
|
|
268
|
+
rescue StandardError
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# A UTF-8 copy of `value`, with invalid bytes replaced, so JSON can encode it.
|
|
273
|
+
#
|
|
274
|
+
# @api private
|
|
275
|
+
# @param value [Object] a file path or test name.
|
|
276
|
+
# @return [String]
|
|
277
|
+
def self.utf8(value)
|
|
278
|
+
str = value.to_s
|
|
279
|
+
str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless str.encoding == Encoding::UTF_8
|
|
280
|
+
str.scrub
|
|
281
|
+
end
|
|
282
|
+
private_class_method :apply, :open?, :parse_line, :emit, :utf8
|
|
283
|
+
end
|
|
284
|
+
end
|