mutineer 1.4.0 → 1.6.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 +475 -0
- data/README.md +224 -28
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +117 -20
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +533 -100
- data/lib/mutineer/daemon_backend.rb +51 -33
- data/lib/mutineer/daemon_client.rb +212 -22
- data/lib/mutineer/daemon_server.rb +74 -27
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +92 -6
- 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 +14 -68
- data/lib/mutineer/minitest_integration.rb +75 -11
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +344 -21
- data/lib/mutineer/rails_worker_db.rb +83 -9
- data/lib/mutineer/reporter.rb +306 -53
- data/lib/mutineer/result.rb +73 -5
- data/lib/mutineer/runner.rb +123 -242
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +282 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +2 -0
- metadata +10 -3
data/lib/mutineer/runner.rb
CHANGED
|
@@ -8,16 +8,14 @@ require_relative "isolation"
|
|
|
8
8
|
require_relative "minitest_integration"
|
|
9
9
|
require_relative "test_runners"
|
|
10
10
|
require_relative "coverage_map"
|
|
11
|
-
require_relative "changed_lines"
|
|
12
11
|
require_relative "mutator_registry"
|
|
13
12
|
require_relative "worker_pool"
|
|
14
13
|
require_relative "progress"
|
|
15
|
-
require_relative "mutant_id"
|
|
16
14
|
require_relative "project_path"
|
|
17
15
|
require_relative "file_swap"
|
|
18
16
|
require_relative "external_backend"
|
|
19
17
|
require_relative "daemon_backend"
|
|
20
|
-
|
|
18
|
+
require_relative "job_plan"
|
|
21
19
|
|
|
22
20
|
module Mutineer
|
|
23
21
|
# Orchestrates one mutation end-to-end: apply it textually, validate the
|
|
@@ -33,7 +31,7 @@ module Mutineer
|
|
|
33
31
|
# Full orchestration: resolve operators, discover subjects, build the
|
|
34
32
|
# coverage map, run every mutation, and aggregate. Returns
|
|
35
33
|
# [AggregateResult, source_map, extras], where extras is the hash
|
|
36
|
-
# {.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
|
|
34
|
+
# {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
|
|
37
35
|
# The CLI then reports + applies the exit code; the integration test asserts
|
|
38
36
|
# directly on the AggregateResult.
|
|
39
37
|
#
|
|
@@ -72,13 +70,20 @@ module Mutineer
|
|
|
72
70
|
# Coverage instruments only files loaded AFTER it starts. Start it BEFORE
|
|
73
71
|
# the boot require so the entire app loaded during boot is instrumented;
|
|
74
72
|
# forked children then measure each test's coverage delta against it.
|
|
73
|
+
# An earlier run in this process leaves Coverage suspended (see below).
|
|
74
|
+
# Coverage a host started is the host's: it is left running.
|
|
75
75
|
require "coverage"
|
|
76
|
-
|
|
76
|
+
own_coverage = !Coverage.running?
|
|
77
|
+
case Coverage.state
|
|
78
|
+
when :idle then Coverage.start(lines: true, methods: true)
|
|
79
|
+
when :suspended then Coverage.resume
|
|
80
|
+
end
|
|
77
81
|
require File.expand_path(config.boot, config.project_root)
|
|
78
82
|
else
|
|
79
83
|
config.sources.each { |f| require File.expand_path(f, config.project_root) }
|
|
80
84
|
end
|
|
81
85
|
config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
|
|
86
|
+
preload_owners(config) if config.boot && config.strategy == "redefine"
|
|
82
87
|
|
|
83
88
|
if config.boot
|
|
84
89
|
# Rails/Minitest test files do `require "test_helper"`, which needs the
|
|
@@ -86,44 +91,51 @@ module Mutineer
|
|
|
86
91
|
# file's helper root here in the parent so loading them in the fork
|
|
87
92
|
# children (both coverage capture and per-mutant) resolves.
|
|
88
93
|
boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
89
|
-
test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
|
|
94
|
+
JobPlan.test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
|
|
90
95
|
|
|
91
96
|
# Boot mode now uses coverage selection too: capture each test's coverage
|
|
92
97
|
# by forking the booted parent, then select covering tests per mutant.
|
|
93
98
|
coverage_map = CoverageMap.new(
|
|
94
99
|
source_paths: config.sources, test_paths: config.tests,
|
|
95
|
-
cache_dir: config.cache_dir, project_root: config.project_root,
|
|
100
|
+
cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
|
|
96
101
|
load_paths: config.load_paths, framework: config.framework,
|
|
97
102
|
boot_path: File.expand_path(config.boot, config.project_root),
|
|
98
|
-
|
|
103
|
+
require_paths: config.require_paths, # loaded above; here only for the cache digest
|
|
104
|
+
verbose: config.verbose,
|
|
105
|
+
capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
99
106
|
).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
|
|
107
|
+
# Nothing reads Coverage once the map is built (cache hit or not), so
|
|
108
|
+
# stop paying for it in every mutant fork (#228).
|
|
109
|
+
Coverage.suspend if own_coverage && Coverage.running?
|
|
100
110
|
else
|
|
101
111
|
# As in boot mode, and with lib first as `rake test` does.
|
|
102
|
-
test_roots = test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
|
|
112
|
+
test_roots = JobPlan.test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
|
|
103
113
|
libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
|
|
104
114
|
$LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
|
|
105
115
|
# Relative, so the cache digest does not depend on the checkout path.
|
|
106
116
|
rel_roots = test_roots.map { |d| Pathname(d).relative_path_from(File.expand_path(config.project_root)).to_s }
|
|
107
117
|
coverage_map = CoverageMap.new(
|
|
108
118
|
source_paths: config.sources, test_paths: config.tests,
|
|
109
|
-
cache_dir: config.cache_dir, project_root: config.project_root,
|
|
110
|
-
load_paths: config.load_paths + rel_roots, framework: config.framework
|
|
119
|
+
cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
|
|
120
|
+
load_paths: config.load_paths + rel_roots, framework: config.framework,
|
|
121
|
+
require_paths: config.require_paths,
|
|
122
|
+
capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
111
123
|
).build_or_load
|
|
112
124
|
end
|
|
113
|
-
abort_if_unclean!(coverage_map)
|
|
125
|
+
JobPlan.abort_if_unclean!(coverage_map)
|
|
114
126
|
|
|
115
127
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
116
|
-
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
128
|
+
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
117
129
|
|
|
118
|
-
jobs = filter_since(jobs, source_map, config) if config.since
|
|
130
|
+
jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
|
|
119
131
|
|
|
120
132
|
# Whole-file reload writes mutineer_mutant*.rb into each source dir (so
|
|
121
133
|
# require_relative resolves). A SIGKILL'd child skips the tempfile's
|
|
122
134
|
# ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
|
|
123
135
|
# PARENT sweeps each source dir before and after the run. Orphans are
|
|
124
136
|
# impossible after a normal run.
|
|
125
|
-
dirs = source_dirs(config)
|
|
126
|
-
sweep_orphans(dirs)
|
|
137
|
+
dirs = JobPlan.source_dirs(config)
|
|
138
|
+
JobPlan.sweep_orphans(dirs)
|
|
127
139
|
|
|
128
140
|
strategy = config.strategy
|
|
129
141
|
# Fail-fast must be serial: a parallel stop_when fires on the first survivor
|
|
@@ -138,85 +150,33 @@ module Mutineer
|
|
|
138
150
|
bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when,
|
|
139
151
|
on_result: ->(_r) { progress.tick }) do |subject, mutation|
|
|
140
152
|
run(mutation, source_file: subject.file, coverage_map: coverage_map,
|
|
141
|
-
subject: subject, strategy: strategy, rails: config.rails, framework: framework
|
|
153
|
+
subject: subject, strategy: strategy, rails: config.rails, framework: framework,
|
|
154
|
+
timeout: config.timeout || Isolation::DEFAULT_TIMEOUT, matrix: config.matrix)
|
|
142
155
|
end
|
|
143
156
|
# The bare Results carry only status (Subjects hold live AST nodes that
|
|
144
157
|
# do not marshal); reattach subject+mutation+id in the parent, in order.
|
|
145
158
|
# filter_map drops nils for jobs --fail-fast left unscheduled.
|
|
146
|
-
bare.each_with_index.filter_map
|
|
159
|
+
share_tests(bare.each_with_index.filter_map do |r, i|
|
|
160
|
+
r = unreported_row(r) if r && config.matrix
|
|
161
|
+
r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2])
|
|
162
|
+
end)
|
|
147
163
|
ensure
|
|
148
|
-
sweep_orphans(dirs)
|
|
164
|
+
JobPlan.sweep_orphans(dirs)
|
|
149
165
|
end
|
|
150
166
|
|
|
151
167
|
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
152
168
|
end
|
|
153
169
|
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
# removed from the killed+survived denominator so a strong file reaches 100%.
|
|
158
|
-
# The id is computed per subject (occurrence needs the full list), keyed on
|
|
159
|
-
# the file path relative to config.project_root, and carried on every job so
|
|
160
|
-
# the parent can reattach it after the run. Shared by the in-process,
|
|
161
|
-
# external, and daemon backends so job selection can never drift.
|
|
162
|
-
#
|
|
163
|
-
# Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
|
|
164
|
-
# entry stored before ids carried the path still suppresses it. Prints nothing:
|
|
165
|
-
# the extras hash returns, as data, `legacy_ignore_matches` (each old-format
|
|
166
|
-
# ignore entry that matched a mutant through its old-format id => one
|
|
167
|
-
# `{id:, file:, subject:}` hash per matched mutant, in collection order: its
|
|
168
|
-
# new id, its project-relative file and its subject's qualified name;
|
|
169
|
-
# recorded even when a new id is also listed, since the old entry still
|
|
170
|
-
# over-matches other files) and `id_map` (every new id => its old-format id).
|
|
170
|
+
# A `--matrix` error that carries no {Kills} row (the worker crashed, or its
|
|
171
|
+
# result was lost) gets an empty, incomplete row: its tests never reported,
|
|
172
|
+
# so a blind test may have killed it, and the report must say so.
|
|
171
173
|
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
# @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
|
|
179
|
-
# jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
|
|
180
|
-
def self.collect_jobs(config, operator_classes)
|
|
181
|
-
source_map = {}
|
|
182
|
-
disabled_map = {}
|
|
183
|
-
id_paths = {}
|
|
184
|
-
# [file, qualified_name] => { declaration offset => ordinal }: keyed by the
|
|
185
|
-
# declaration, so the same file discovered twice (two path spellings) reuses
|
|
186
|
-
# its ordinal instead of minting a second id for the same mutant.
|
|
187
|
-
name_decls = Hash.new { |h, k| h[k] = {} }
|
|
188
|
-
ignore_set = config.ignore.to_set
|
|
189
|
-
jobs = []
|
|
190
|
-
ignored_results = []
|
|
191
|
-
legacy_ignore_matches = {}
|
|
192
|
-
id_map = {}
|
|
193
|
-
Project.discover(config.sources, only: config.only).each do |subject|
|
|
194
|
-
source = (source_map[subject.file] ||= File.read(subject.file))
|
|
195
|
-
disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
|
|
196
|
-
mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
|
|
197
|
-
id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
|
|
198
|
-
decls = name_decls[[id_path, subject.qualified_name]]
|
|
199
|
-
ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
|
|
200
|
-
ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
|
|
201
|
-
legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
|
|
202
|
-
mutations.each_with_index do |mutation, i|
|
|
203
|
-
id = ids[i]
|
|
204
|
-
legacy = legacy_ids[i]
|
|
205
|
-
id_map[id] = legacy
|
|
206
|
-
# An old entry still over-matches other files even when the new id is
|
|
207
|
-
# listed too, so every old-entry match is reported for migration.
|
|
208
|
-
if ignore_set.include?(legacy)
|
|
209
|
-
(legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
|
|
210
|
-
end
|
|
211
|
-
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
212
|
-
if suppressed?(mutation.operator, line, [id, legacy], disabled, ignore_set)
|
|
213
|
-
ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
|
|
214
|
-
else
|
|
215
|
-
jobs << [subject, mutation, id]
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
[jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
|
|
174
|
+
# @param result [Mutineer::Result] a worker's result.
|
|
175
|
+
# @return [Mutineer::Result]
|
|
176
|
+
def self.unreported_row(result)
|
|
177
|
+
return result unless result.error? && result.kills.nil?
|
|
178
|
+
|
|
179
|
+
result.with(kills: Kills.new(killed_by: [], ran: [], complete: false))
|
|
220
180
|
end
|
|
221
181
|
|
|
222
182
|
# External backend orchestration. Runs each mutant's whole-file mutation on
|
|
@@ -229,7 +189,7 @@ module Mutineer
|
|
|
229
189
|
# @param config [Mutineer::Config] run configuration (test_command set).
|
|
230
190
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
231
191
|
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
232
|
-
# source map, and the {.collect_jobs} extras.
|
|
192
|
+
# source map, and the {JobPlan.collect_jobs} extras.
|
|
233
193
|
def self.execute_external(config, operator_classes)
|
|
234
194
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
235
195
|
sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
|
|
@@ -245,8 +205,8 @@ module Mutineer
|
|
|
245
205
|
# source. Heal first, then discover jobs from the clean tree.
|
|
246
206
|
FileSwap.restore_orphans(dirs)
|
|
247
207
|
|
|
248
|
-
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
249
|
-
jobs = filter_since(jobs, source_map, config) if config.since
|
|
208
|
+
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
209
|
+
jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
|
|
250
210
|
|
|
251
211
|
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
252
212
|
# --test set to calibrate a timeout no mutant would use (#76).
|
|
@@ -295,135 +255,29 @@ module Mutineer
|
|
|
295
255
|
end
|
|
296
256
|
end
|
|
297
257
|
|
|
298
|
-
#
|
|
299
|
-
#
|
|
258
|
+
# Loads the class or module of every subject in the booted parent, before
|
|
259
|
+
# the boot coverage is read. Under redefine a child resolves the owner by
|
|
260
|
+
# name ({Isolation.nesting_keywords}), so a lazily loaded class (Zeitwerk,
|
|
261
|
+
# `autoload`) would run its class body there, with the original method,
|
|
262
|
+
# and that run would count as no load at all. Loading it here makes its
|
|
263
|
+
# class-body calls load lines, the same as an eager load. A constant that
|
|
264
|
+
# fails to load stays lazy.
|
|
300
265
|
#
|
|
301
|
-
# @param
|
|
266
|
+
# @param config [Mutineer::Config] run configuration.
|
|
302
267
|
# @return [void]
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
raise SmokeCheckError,
|
|
314
|
-
"the unmutated suite is not green (#{files.join(', ')}) — " \
|
|
315
|
-
"#{ExternalBackend.generic_env_hint}."
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
# Coverage-based test selection, shared by the in-process ({run}) and daemon
|
|
319
|
-
# paths so both narrow identically (score parity). Returns
|
|
320
|
-
# `[:run, abs_test_paths]` when some test covers the mutant's line, or
|
|
321
|
-
# `[:verdict, Result]` (no_coverage / uncapturable) when none do.
|
|
322
|
-
#
|
|
323
|
-
# An empty selection is `:uncapturable` (not `:no_coverage`) when the
|
|
324
|
-
# mutant's enclosing method body got coverage from no *successful* capture but
|
|
325
|
-
# a sibling test failed to capture: the coverage was lost, not absent. Both are
|
|
326
|
-
# excluded from the score denominator, so this distinction is reporting-only
|
|
327
|
-
# and never changes the daemon-vs-in-process score.
|
|
328
|
-
#
|
|
329
|
-
# @param source_file [String] the mutated source file path.
|
|
330
|
-
# @param mutation [Mutineer::Mutation] the mutation (for its line offset).
|
|
331
|
-
# @param subject [Mutineer::Subject, nil] the subject (for its method body range).
|
|
332
|
-
# @param source [String] the original source text.
|
|
333
|
-
# @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
|
|
334
|
-
# @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
|
|
335
|
-
def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
336
|
-
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
337
|
-
chosen = coverage_map.tests_for(source_file, line)
|
|
338
|
-
if chosen.empty?
|
|
339
|
-
# Method BODY range, not the whole def: the def/end lines are "covered" at
|
|
340
|
-
# class-load even when the body never runs (body_loc is the statements' span).
|
|
341
|
-
loc = subject&.body_loc
|
|
342
|
-
range = loc ? (loc.start_line..loc.end_line) : (line..line)
|
|
343
|
-
return [:verdict, coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage]
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
[:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
# Scan a source once into { line_number => :all | Set[operator_syms] } from
|
|
350
|
-
# inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
|
|
351
|
-
# sits on the same physical line as the code it silences). A bare marker
|
|
352
|
-
# disables every operator on that line; `disable-line a, b` only the listed
|
|
353
|
-
# operators. Block-form disable/enable ranges are intentionally not supported.
|
|
354
|
-
def self.suppress_map(source, file)
|
|
355
|
-
map = {}
|
|
356
|
-
source.each_line.with_index(1) do |text, line|
|
|
357
|
-
next unless (m = text.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
|
|
358
|
-
|
|
359
|
-
ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
|
|
360
|
-
# Only spaces or commas after the marker (e.g. `disable-line -- why`)
|
|
361
|
-
# is a bare marker, not an empty list that silences nothing.
|
|
362
|
-
ops = nil if ops&.empty?
|
|
363
|
-
unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
|
|
364
|
-
unknown.each do |o|
|
|
365
|
-
warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
|
|
366
|
-
"(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
|
|
367
|
-
end
|
|
368
|
-
map[line] = ops ? ops.map(&:to_sym).to_set : :all
|
|
369
|
-
end
|
|
370
|
-
map
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
# True when this mutant is suppressed: its line bears a disable-line marker
|
|
374
|
-
# (bare, or scoped to its operator), OR its new or old-format id is in the
|
|
375
|
-
# config ignore list. Checked at job-build time so a suppressed mutant is
|
|
376
|
-
# never forked.
|
|
377
|
-
#
|
|
378
|
-
# @param ids [Array<String>, String] the mutant's new id and its old-format
|
|
379
|
-
# id, or a single id (the pre-#126 call shape).
|
|
380
|
-
def self.suppressed?(operator, line, ids, disabled, ignore_set)
|
|
381
|
-
return true if Array(ids).any? { |id| ignore_set.include?(id) }
|
|
382
|
-
|
|
383
|
-
case (entry = disabled[line])
|
|
384
|
-
when :all then true
|
|
385
|
-
when Set then entry.include?(operator)
|
|
386
|
-
else false
|
|
387
|
-
end
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
# --since: keep only jobs whose mutation lands on a line changed since the git
|
|
391
|
-
# ref. Composes with coverage selection (it only narrows the job list; each
|
|
392
|
-
# surviving mutant still goes through Runner.run's coverage check). A file with
|
|
393
|
-
# no changed lines (absent from the diff) contributes no jobs. Line is computed
|
|
394
|
-
# exactly as Runner.run does, from the already-read source in source_map.
|
|
395
|
-
def self.filter_since(jobs, source_map, config)
|
|
396
|
-
changed = ChangedLines.for(ref: config.since, files: config.sources,
|
|
397
|
-
project_root: config.project_root)
|
|
398
|
-
jobs.select do |subject, mutation|
|
|
399
|
-
source = source_map[subject.file]
|
|
400
|
-
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
401
|
-
abs = File.expand_path(subject.file, config.project_root)
|
|
402
|
-
changed.fetch(abs, []).include?(line)
|
|
268
|
+
def self.preload_owners(config)
|
|
269
|
+
Project.discover(config.sources, only: config.only).each do |subject|
|
|
270
|
+
next if subject.owner_unknown
|
|
271
|
+
|
|
272
|
+
Isolation.nesting_keywords(subject.lexical_namespace)
|
|
273
|
+
# The owner itself, as the child resolves it: a Class.new block owner
|
|
274
|
+
# has no lexical namespace.
|
|
275
|
+
Object.const_get(subject.namespace.join("::")) unless subject.namespace.empty?
|
|
276
|
+
rescue StandardError, ScriptError
|
|
277
|
+
nil
|
|
403
278
|
end
|
|
404
279
|
end
|
|
405
280
|
|
|
406
|
-
# For each test file, the directory to add to $LOAD_PATH so its
|
|
407
|
-
# `require "test_helper"` (or spec_helper) resolves: the nearest ancestor
|
|
408
|
-
# holding that helper, plus the file's own dir as a fallback.
|
|
409
|
-
def self.test_load_roots(test_files)
|
|
410
|
-
test_files.flat_map do |f|
|
|
411
|
-
dir = File.dirname(f)
|
|
412
|
-
root = nil
|
|
413
|
-
loop do
|
|
414
|
-
if File.exist?(File.join(dir, "test_helper.rb")) || File.exist?(File.join(dir, "spec_helper.rb"))
|
|
415
|
-
root = dir
|
|
416
|
-
break
|
|
417
|
-
end
|
|
418
|
-
parent = File.dirname(dir)
|
|
419
|
-
break if parent == dir
|
|
420
|
-
|
|
421
|
-
dir = parent
|
|
422
|
-
end
|
|
423
|
-
[root, File.dirname(f)].compact
|
|
424
|
-
end.uniq
|
|
425
|
-
end
|
|
426
|
-
|
|
427
281
|
# When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
|
|
428
282
|
# before the app boots. Otherwise it boots development and nothing is measured.
|
|
429
283
|
# An explicitly-set RAILS_ENV is always respected.
|
|
@@ -435,32 +289,6 @@ module Mutineer
|
|
|
435
289
|
warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
|
|
436
290
|
end
|
|
437
291
|
|
|
438
|
-
# The unique absolute directories holding the sources. Sweep target for both
|
|
439
|
-
# orphan mechanisms (in-process mutant tempfiles and external backup files),
|
|
440
|
-
# and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
|
|
441
|
-
# Shared so the path-expansion rule cannot drift between the paths.
|
|
442
|
-
#
|
|
443
|
-
# @param config [Mutineer::Config] run configuration.
|
|
444
|
-
# @return [Array<String>] unique absolute source directories.
|
|
445
|
-
def self.source_dirs(config)
|
|
446
|
-
config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
# Removes stale mutant tempfiles from the given directories. The daemon writes a
|
|
450
|
-
# differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
|
|
451
|
-
# tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
|
|
452
|
-
#
|
|
453
|
-
# @param dirs [Array<String>] directories to sweep.
|
|
454
|
-
# @param glob [String] filename pattern to remove.
|
|
455
|
-
# @return [void]
|
|
456
|
-
def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
|
|
457
|
-
dirs.each do |dir|
|
|
458
|
-
Dir.glob(File.join(dir, glob)).each do |f|
|
|
459
|
-
File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
|
|
460
|
-
end
|
|
461
|
-
end
|
|
462
|
-
end
|
|
463
|
-
|
|
464
292
|
# Runs a single mutation through isolation.
|
|
465
293
|
#
|
|
466
294
|
# @param mutation [Mutineer::Mutation] mutation to run.
|
|
@@ -471,9 +299,11 @@ module Mutineer
|
|
|
471
299
|
# @param timeout [Integer] child timeout in seconds.
|
|
472
300
|
# @param rails [Boolean] whether Rails reconnect handling is enabled.
|
|
473
301
|
# @param framework [String] test framework name.
|
|
302
|
+
# @param matrix [Boolean] a `--matrix` run: run every covering test and
|
|
303
|
+
# attach the {Kills} row, with project-relative test files.
|
|
474
304
|
# @return [Mutineer::Result] mutant result.
|
|
475
305
|
def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
|
|
476
|
-
timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest")
|
|
306
|
+
timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest", matrix: false)
|
|
477
307
|
source = File.read(source_file)
|
|
478
308
|
mutated = mutation.apply(source)
|
|
479
309
|
|
|
@@ -484,12 +314,13 @@ module Mutineer
|
|
|
484
314
|
# no test exercises is :no_coverage (no fork); otherwise exactly the
|
|
485
315
|
# covering test files run in the child. Shared with the daemon path so both
|
|
486
316
|
# narrow identically (score parity).
|
|
487
|
-
kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
317
|
+
kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
488
318
|
return payload if kind == :verdict
|
|
319
|
+
return Result.unplaceable if strategy == "redefine" && subject&.owner_unknown
|
|
489
320
|
|
|
490
321
|
abs_tests = payload
|
|
491
322
|
|
|
492
|
-
Isolation.run(timeout: timeout) do
|
|
323
|
+
result = Isolation.run(timeout: timeout, channel: matrix) do |channel|
|
|
493
324
|
# Forking inherits the parent's live DB connection; sharing one socket
|
|
494
325
|
# across processes corrupts it. Drop it so AR reconnects per child.
|
|
495
326
|
reconnect_active_record if rails
|
|
@@ -498,8 +329,58 @@ module Mutineer
|
|
|
498
329
|
else
|
|
499
330
|
Isolation.apply_whole_file(mutated, source_file)
|
|
500
331
|
end
|
|
501
|
-
|
|
502
|
-
|
|
332
|
+
if channel
|
|
333
|
+
# --matrix: every covering test runs, and each outcome goes to the parent.
|
|
334
|
+
TestRunners.for(framework).run(abs_tests, record_to: channel)
|
|
335
|
+
else
|
|
336
|
+
# One failing test already kills the mutant, so the child stops there.
|
|
337
|
+
TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
result = relative_kills(result, coverage_map.project_root)
|
|
341
|
+
JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Rewrites the test files of a result's {Kills} row relative to the project
|
|
345
|
+
# root, the form the coverage map and the report use. A result without a
|
|
346
|
+
# row comes back unchanged.
|
|
347
|
+
#
|
|
348
|
+
# @api private
|
|
349
|
+
# @param result [Mutineer::Result] a mutant result.
|
|
350
|
+
# @param root [String] project root.
|
|
351
|
+
# @return [Mutineer::Result]
|
|
352
|
+
def self.relative_kills(result, root)
|
|
353
|
+
return result unless (kills = result.kills)
|
|
354
|
+
|
|
355
|
+
paths = {}
|
|
356
|
+
rel = lambda do |tests|
|
|
357
|
+
tests.map { |file, name, id| [(paths[file] ||= ProjectPath.relative(file, root)), name, id] }.uniq.sort
|
|
358
|
+
end
|
|
359
|
+
result.with(kills: kills.with(killed_by: rel.call(kills.killed_by), ran: rel.call(kills.ran)))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Makes every {Kills} row refer to one shared, frozen array per test. Each
|
|
363
|
+
# row arrives with its own copy of every test it ran, and a large matrix
|
|
364
|
+
# (thousands of mutants times hundreds of tests) would otherwise hold that
|
|
365
|
+
# many copies. Results without a row come back unchanged.
|
|
366
|
+
#
|
|
367
|
+
# A test is its file and its id. The name is display data and can differ
|
|
368
|
+
# between mutants (an RSpec example worded from its matcher reads
|
|
369
|
+
# differently under each mutant), so every row gets the name the first
|
|
370
|
+
# mutant reported, and one example stays one test.
|
|
371
|
+
#
|
|
372
|
+
# @api private
|
|
373
|
+
# @param results [Array<Mutineer::Result>] mutant results.
|
|
374
|
+
# @return [Array<Mutineer::Result>]
|
|
375
|
+
def self.share_tests(results)
|
|
376
|
+
shared = {}
|
|
377
|
+
share = lambda do |tests|
|
|
378
|
+
tests.map { |file, name, id| shared[[file, id]] ||= [file, name, id].map { |s| -s }.freeze }.uniq.sort.freeze
|
|
379
|
+
end
|
|
380
|
+
results.map do |r|
|
|
381
|
+
next r unless (kills = r.kills)
|
|
382
|
+
|
|
383
|
+
r.with(kills: kills.with(killed_by: share.call(kills.killed_by), ran: share.call(kills.ran)))
|
|
503
384
|
end
|
|
504
385
|
end
|
|
505
386
|
|