mutineer 1.5.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 +225 -0
- data/README.md +28 -3
- data/lib/mutineer/cli.rb +5 -4
- data/lib/mutineer/coverage_map.rb +372 -86
- data/lib/mutineer/daemon_backend.rb +48 -33
- data/lib/mutineer/daemon_client.rb +42 -9
- data/lib/mutineer/daemon_server.rb +73 -27
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +1 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/project.rb +337 -21
- data/lib/mutineer/rails_worker_db.rb +82 -8
- data/lib/mutineer/reporter.rb +20 -3
- data/lib/mutineer/result.rb +36 -4
- data/lib/mutineer/runner.rb +48 -284
- data/lib/mutineer/statement_lines.rb +40 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/rspec.rb +39 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +1 -0
- metadata +3 -1
data/lib/mutineer/result.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mutineer
|
|
4
|
-
# Immutable outcome of running one mutant.
|
|
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
7
|
# error - the child crashed (unhandled exception): exit status 2.
|
|
@@ -13,6 +13,19 @@ module Mutineer
|
|
|
13
13
|
# like no_coverage, but reported separately: it signals a
|
|
14
14
|
# broken harness (a test that failed to run), not a genuine
|
|
15
15
|
# coverage gap.
|
|
16
|
+
# unplaceable - under `--strategy redefine`, the method belongs to a class
|
|
17
|
+
# or module that cannot be named statically, so it has no
|
|
18
|
+
# owner to be loaded onto and is not run. Excluded from the
|
|
19
|
+
# denominator and, unlike uncapturable, from the
|
|
20
|
+
# no-verdict gate: nothing is broken. `--strategy reload`
|
|
21
|
+
# runs these mutants.
|
|
22
|
+
# ran_at_load - the mutated line ran while the app booted or its class
|
|
23
|
+
# loaded. That run happened before the mutant was applied,
|
|
24
|
+
# so a test can check a value the original code computed.
|
|
25
|
+
# A survivor on such a line, and a line that ran only at
|
|
26
|
+
# load, get this status; a kill stays killed. Excluded from
|
|
27
|
+
# the denominator and from the no-verdict gate, like
|
|
28
|
+
# unplaceable. `--test-command` verifies these mutants.
|
|
16
29
|
# ignored - a known-equivalent mutant the user suppressed, via an
|
|
17
30
|
# inline `# mutineer:disable-line` comment or a
|
|
18
31
|
# `.mutineer.yml` `ignore:` id. A pre-fork classification
|
|
@@ -21,8 +34,9 @@ module Mutineer
|
|
|
21
34
|
#
|
|
22
35
|
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
23
36
|
# validity failure (counted separately by the reporter), error is a runtime
|
|
24
|
-
# crash. Never conflate them via `details` string parsing. `no_coverage
|
|
25
|
-
# `uncapturable` are pre-fork
|
|
37
|
+
# crash. Never conflate them via `details` string parsing. `no_coverage`,
|
|
38
|
+
# `uncapturable` and `unplaceable` are pre-fork results, and `ran_at_load` is
|
|
39
|
+
# pre-fork or a reclassified survivor: all excluded from the score
|
|
26
40
|
# denominator.
|
|
27
41
|
#
|
|
28
42
|
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
@@ -89,6 +103,16 @@ module Mutineer
|
|
|
89
103
|
# @return [Mutineer::Result] uncapturable result.
|
|
90
104
|
def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
91
105
|
|
|
106
|
+
# Builds an unplaceable result.
|
|
107
|
+
#
|
|
108
|
+
# @return [Mutineer::Result] unplaceable result.
|
|
109
|
+
def self.unplaceable = new(status: :unplaceable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
110
|
+
|
|
111
|
+
# Builds a ran_at_load result.
|
|
112
|
+
#
|
|
113
|
+
# @return [Mutineer::Result] ran-at-load result.
|
|
114
|
+
def self.ran_at_load = new(status: :ran_at_load, details: nil, subject: nil, mutation: nil, id: nil)
|
|
115
|
+
|
|
92
116
|
# Builds an ignored result.
|
|
93
117
|
#
|
|
94
118
|
# @return [Mutineer::Result] ignored result.
|
|
@@ -108,6 +132,10 @@ module Mutineer
|
|
|
108
132
|
def no_coverage? = status == :no_coverage
|
|
109
133
|
# @return [Boolean] true when the status is uncapturable.
|
|
110
134
|
def uncapturable? = status == :uncapturable
|
|
135
|
+
# @return [Boolean] true when the status is unplaceable.
|
|
136
|
+
def unplaceable? = status == :unplaceable
|
|
137
|
+
# @return [Boolean] true when the status is ran_at_load.
|
|
138
|
+
def ran_at_load? = status == :ran_at_load
|
|
111
139
|
# @return [Boolean] true when the status is ignored.
|
|
112
140
|
def ignored? = status == :ignored
|
|
113
141
|
|
|
@@ -130,7 +158,7 @@ module Mutineer
|
|
|
130
158
|
|
|
131
159
|
# Aggregates a flat list of Results into counts, the mutation score, and the
|
|
132
160
|
# surviving-mutant list. The score denominator is killed + survived ONLY:
|
|
133
|
-
# no-coverage, uncapturable, skipped (invalid), errored, timeout, and ignored
|
|
161
|
+
# no-coverage, uncapturable, unplaceable, ran-at-load, skipped (invalid), errored, timeout, and ignored
|
|
134
162
|
# (equivalent-mutant suppression) are each excluded and surfaced separately,
|
|
135
163
|
# so suppressing every survivor reaches 100%. An empty denominator yields a
|
|
136
164
|
# nil score (rendered "N/A"), never 0.0, distinguishing "no testable mutants"
|
|
@@ -154,6 +182,10 @@ module Mutineer
|
|
|
154
182
|
def no_coverage_count = count(:no_coverage)
|
|
155
183
|
# @return [Integer] uncapturable count.
|
|
156
184
|
def uncapturable_count = count(:uncapturable)
|
|
185
|
+
# @return [Integer] unplaceable count.
|
|
186
|
+
def unplaceable_count = count(:unplaceable)
|
|
187
|
+
# @return [Integer] ran-at-load count.
|
|
188
|
+
def ran_at_load_count = count(:ran_at_load)
|
|
157
189
|
# @return [Integer] skipped-invalid count.
|
|
158
190
|
def skipped_invalid_count = count(:skipped)
|
|
159
191
|
# @return [Integer] errored count.
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "digest"
|
|
4
3
|
require "pathname"
|
|
5
4
|
require_relative "parser"
|
|
6
5
|
require_relative "project"
|
|
7
6
|
require_relative "result"
|
|
8
|
-
require_relative "statement_lines"
|
|
9
7
|
require_relative "isolation"
|
|
10
8
|
require_relative "minitest_integration"
|
|
11
9
|
require_relative "test_runners"
|
|
12
10
|
require_relative "coverage_map"
|
|
13
|
-
require_relative "changed_lines"
|
|
14
11
|
require_relative "mutator_registry"
|
|
15
12
|
require_relative "worker_pool"
|
|
16
13
|
require_relative "progress"
|
|
17
|
-
require_relative "mutant_id"
|
|
18
14
|
require_relative "project_path"
|
|
19
15
|
require_relative "file_swap"
|
|
20
16
|
require_relative "external_backend"
|
|
21
17
|
require_relative "daemon_backend"
|
|
22
|
-
|
|
18
|
+
require_relative "job_plan"
|
|
23
19
|
|
|
24
20
|
module Mutineer
|
|
25
21
|
# Orchestrates one mutation end-to-end: apply it textually, validate the
|
|
@@ -35,7 +31,7 @@ module Mutineer
|
|
|
35
31
|
# Full orchestration: resolve operators, discover subjects, build the
|
|
36
32
|
# coverage map, run every mutation, and aggregate. Returns
|
|
37
33
|
# [AggregateResult, source_map, extras], where extras is the hash
|
|
38
|
-
# {.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
|
|
34
|
+
# {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
|
|
39
35
|
# The CLI then reports + applies the exit code; the integration test asserts
|
|
40
36
|
# directly on the AggregateResult.
|
|
41
37
|
#
|
|
@@ -74,13 +70,20 @@ module Mutineer
|
|
|
74
70
|
# Coverage instruments only files loaded AFTER it starts. Start it BEFORE
|
|
75
71
|
# the boot require so the entire app loaded during boot is instrumented;
|
|
76
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.
|
|
77
75
|
require "coverage"
|
|
78
|
-
|
|
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
|
|
79
81
|
require File.expand_path(config.boot, config.project_root)
|
|
80
82
|
else
|
|
81
83
|
config.sources.each { |f| require File.expand_path(f, config.project_root) }
|
|
82
84
|
end
|
|
83
85
|
config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
|
|
86
|
+
preload_owners(config) if config.boot && config.strategy == "redefine"
|
|
84
87
|
|
|
85
88
|
if config.boot
|
|
86
89
|
# Rails/Minitest test files do `require "test_helper"`, which needs the
|
|
@@ -88,7 +91,7 @@ module Mutineer
|
|
|
88
91
|
# file's helper root here in the parent so loading them in the fork
|
|
89
92
|
# children (both coverage capture and per-mutant) resolves.
|
|
90
93
|
boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
91
|
-
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) }
|
|
92
95
|
|
|
93
96
|
# Boot mode now uses coverage selection too: capture each test's coverage
|
|
94
97
|
# by forking the booted parent, then select covering tests per mutant.
|
|
@@ -97,12 +100,16 @@ module Mutineer
|
|
|
97
100
|
cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
|
|
98
101
|
load_paths: config.load_paths, framework: config.framework,
|
|
99
102
|
boot_path: File.expand_path(config.boot, config.project_root),
|
|
103
|
+
require_paths: config.require_paths, # loaded above; here only for the cache digest
|
|
100
104
|
verbose: config.verbose,
|
|
101
105
|
capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
102
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?
|
|
103
110
|
else
|
|
104
111
|
# As in boot mode, and with lib first as `rake test` does.
|
|
105
|
-
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) })
|
|
106
113
|
libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
|
|
107
114
|
$LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
|
|
108
115
|
# Relative, so the cache digest does not depend on the checkout path.
|
|
@@ -111,23 +118,24 @@ module Mutineer
|
|
|
111
118
|
source_paths: config.sources, test_paths: config.tests,
|
|
112
119
|
cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
|
|
113
120
|
load_paths: config.load_paths + rel_roots, framework: config.framework,
|
|
121
|
+
require_paths: config.require_paths,
|
|
114
122
|
capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
115
123
|
).build_or_load
|
|
116
124
|
end
|
|
117
|
-
abort_if_unclean!(coverage_map)
|
|
125
|
+
JobPlan.abort_if_unclean!(coverage_map)
|
|
118
126
|
|
|
119
127
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
120
|
-
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
128
|
+
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
121
129
|
|
|
122
|
-
jobs = filter_since(jobs, source_map, config) if config.since
|
|
130
|
+
jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
|
|
123
131
|
|
|
124
132
|
# Whole-file reload writes mutineer_mutant*.rb into each source dir (so
|
|
125
133
|
# require_relative resolves). A SIGKILL'd child skips the tempfile's
|
|
126
134
|
# ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
|
|
127
135
|
# PARENT sweeps each source dir before and after the run. Orphans are
|
|
128
136
|
# impossible after a normal run.
|
|
129
|
-
dirs = source_dirs(config)
|
|
130
|
-
sweep_orphans(dirs)
|
|
137
|
+
dirs = JobPlan.source_dirs(config)
|
|
138
|
+
JobPlan.sweep_orphans(dirs)
|
|
131
139
|
|
|
132
140
|
strategy = config.strategy
|
|
133
141
|
# Fail-fast must be serial: a parallel stop_when fires on the first survivor
|
|
@@ -153,7 +161,7 @@ module Mutineer
|
|
|
153
161
|
r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2])
|
|
154
162
|
end)
|
|
155
163
|
ensure
|
|
156
|
-
sweep_orphans(dirs)
|
|
164
|
+
JobPlan.sweep_orphans(dirs)
|
|
157
165
|
end
|
|
158
166
|
|
|
159
167
|
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
@@ -171,109 +179,6 @@ module Mutineer
|
|
|
171
179
|
result.with(kills: Kills.new(killed_by: [], ran: [], complete: false))
|
|
172
180
|
end
|
|
173
181
|
|
|
174
|
-
# Collect every (subject, mutation, id) up front so a backend can run them.
|
|
175
|
-
# A mutant the user marked known-equivalent (inline disable-line comment or
|
|
176
|
-
# .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
|
|
177
|
-
# removed from the killed+survived denominator so a strong file reaches 100%.
|
|
178
|
-
# The id is computed per subject (occurrence needs the full list), keyed on
|
|
179
|
-
# the file path relative to config.project_root, and carried on every job so
|
|
180
|
-
# the parent can reattach it after the run. Shared by the in-process,
|
|
181
|
-
# external, and daemon backends so job selection can never drift.
|
|
182
|
-
#
|
|
183
|
-
# Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
|
|
184
|
-
# entry stored before ids carried the path still suppresses it. Prints nothing:
|
|
185
|
-
# the extras hash returns, as data, `legacy_ignore_matches` (each old-format
|
|
186
|
-
# ignore entry that matched a mutant through its old-format id => one
|
|
187
|
-
# `{id:, file:, subject:}` hash per matched mutant, in collection order: its
|
|
188
|
-
# new id, its project-relative file and its subject's qualified name;
|
|
189
|
-
# recorded even when a new id is also listed, since the old entry still
|
|
190
|
-
# over-matches other files) and `id_map` (every new id => its old-format id).
|
|
191
|
-
#
|
|
192
|
-
# Subjects sharing a qualified name in one file (two owner-less `def index`
|
|
193
|
-
# in two DSL blocks) get a per-file ordinal in discovery order, so their ids
|
|
194
|
-
# differ; the first one's ordinal is 0 and leaves its id unchanged.
|
|
195
|
-
#
|
|
196
|
-
# @param config [Mutineer::Config] run configuration.
|
|
197
|
-
# @param operator_classes [Array<Class>] resolved operators.
|
|
198
|
-
# @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
|
|
199
|
-
# jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
|
|
200
|
-
def self.collect_jobs(config, operator_classes)
|
|
201
|
-
source_map = {}
|
|
202
|
-
disabled_map = {}
|
|
203
|
-
id_paths = {}
|
|
204
|
-
# [file, qualified_name] => { declaration offset => ordinal }: keyed by the
|
|
205
|
-
# declaration, so the same file discovered twice (two path spellings) reuses
|
|
206
|
-
# its ordinal instead of minting a second id for the same mutant.
|
|
207
|
-
name_decls = Hash.new { |h, k| h[k] = {} }
|
|
208
|
-
ignore_set = config.ignore.to_set
|
|
209
|
-
jobs = []
|
|
210
|
-
ignored_results = []
|
|
211
|
-
legacy_ignore_matches = {}
|
|
212
|
-
id_map = {}
|
|
213
|
-
Project.discover(config.sources, only: config.only).each do |subject|
|
|
214
|
-
source = (source_map[subject.file] ||= File.read(subject.file))
|
|
215
|
-
disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
|
|
216
|
-
mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
|
|
217
|
-
id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
|
|
218
|
-
decls = name_decls[[id_path, subject.qualified_name]]
|
|
219
|
-
ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
|
|
220
|
-
ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
|
|
221
|
-
legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
|
|
222
|
-
lines = mutations.map { |m| source.byteslice(0, m.start_offset).count("\n") + 1 }
|
|
223
|
-
keys = result_keys(mutations, source, lines)
|
|
224
|
-
# A repeat of an earlier edit on the same line is dropped (#159),
|
|
225
|
-
# separately among the run and the ignored mutants, so an ignored copy
|
|
226
|
-
# never hides a copy that should run. A dropped copy records nothing.
|
|
227
|
-
seen = { run: Set.new, ignored: Set.new }
|
|
228
|
-
mutations.each_with_index do |mutation, i|
|
|
229
|
-
id = ids[i]
|
|
230
|
-
legacy = legacy_ids[i]
|
|
231
|
-
ignored = suppressed?(mutation.operator, lines[i], [id, legacy], disabled, ignore_set)
|
|
232
|
-
next unless seen[ignored ? :ignored : :run].add?(keys[i])
|
|
233
|
-
|
|
234
|
-
id_map[id] = legacy
|
|
235
|
-
# An old entry still over-matches other files even when the new id is
|
|
236
|
-
# listed too, so every old-entry match is reported for migration.
|
|
237
|
-
if ignore_set.include?(legacy)
|
|
238
|
-
(legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
|
|
239
|
-
end
|
|
240
|
-
if ignored
|
|
241
|
-
ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
|
|
242
|
-
else
|
|
243
|
-
jobs << [subject, mutation, id]
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
[jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# One key per mutation; two mutations share a key exactly when they are
|
|
251
|
-
# the same operator on the same line and give the same mutated source (one
|
|
252
|
-
# edit, #159). The caller drops a repeat after ids are assigned. Only a
|
|
253
|
-
# group of same-operator, same-line mutations can repeat, so only those
|
|
254
|
-
# build a key from their text: the span from the group's earliest start to
|
|
255
|
-
# its latest end, digested. Every other mutation gets a key of its own.
|
|
256
|
-
#
|
|
257
|
-
# @param mutations [Array<Mutineer::Mutation>] one subject's mutations.
|
|
258
|
-
# @param source [String] the full, unmutated source.
|
|
259
|
-
# @param lines [Array<Integer>] each mutation's line.
|
|
260
|
-
# @return [Array<Object>] one key per mutation, in order.
|
|
261
|
-
def self.result_keys(mutations, source, lines)
|
|
262
|
-
keys = Array.new(mutations.size) { |i| i }
|
|
263
|
-
mutations.each_index.group_by { |i| [mutations[i].operator, lines[i]] }.each_value do |group|
|
|
264
|
-
next if group.size < 2
|
|
265
|
-
|
|
266
|
-
from = group.map { |i| mutations[i].start_offset }.min
|
|
267
|
-
to = group.map { |i| mutations[i].end_offset }.max
|
|
268
|
-
group.each do |i|
|
|
269
|
-
m = mutations[i]
|
|
270
|
-
span = "#{source.byteslice(from...m.start_offset)}#{m.replacement}#{source.byteslice(m.end_offset...to)}"
|
|
271
|
-
keys[i] = [m.operator, lines[i], Digest::SHA256.digest(span)]
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
keys
|
|
275
|
-
end
|
|
276
|
-
|
|
277
182
|
# External backend orchestration. Runs each mutant's whole-file mutation on
|
|
278
183
|
# disk (crash-safe swap) and executes the user's --test-command as a subprocess
|
|
279
184
|
# in the app's own runtime. Serial by construction (one shared DB, no
|
|
@@ -284,7 +189,7 @@ module Mutineer
|
|
|
284
189
|
# @param config [Mutineer::Config] run configuration (test_command set).
|
|
285
190
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
286
191
|
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
287
|
-
# source map, and the {.collect_jobs} extras.
|
|
192
|
+
# source map, and the {JobPlan.collect_jobs} extras.
|
|
288
193
|
def self.execute_external(config, operator_classes)
|
|
289
194
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
290
195
|
sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
|
|
@@ -300,8 +205,8 @@ module Mutineer
|
|
|
300
205
|
# source. Heal first, then discover jobs from the clean tree.
|
|
301
206
|
FileSwap.restore_orphans(dirs)
|
|
302
207
|
|
|
303
|
-
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
304
|
-
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
|
|
305
210
|
|
|
306
211
|
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
307
212
|
# --test set to calibrate a timeout no mutant would use (#76).
|
|
@@ -350,146 +255,29 @@ module Mutineer
|
|
|
350
255
|
end
|
|
351
256
|
end
|
|
352
257
|
|
|
353
|
-
#
|
|
354
|
-
#
|
|
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.
|
|
355
265
|
#
|
|
356
|
-
# @param
|
|
266
|
+
# @param config [Mutineer::Config] run configuration.
|
|
357
267
|
# @return [void]
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
raise SmokeCheckError,
|
|
369
|
-
"the unmutated suite is not green (#{files.join(', ')}) — " \
|
|
370
|
-
"#{ExternalBackend.generic_env_hint}."
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
# Coverage-based test selection, shared by the in-process ({run}) and daemon
|
|
374
|
-
# paths so both narrow identically (score parity). Returns
|
|
375
|
-
# `[:run, abs_test_paths]` when some test covers the mutant's line, or
|
|
376
|
-
# `[:verdict, Result]` (no_coverage / uncapturable) when none do. A line
|
|
377
|
-
# Ruby does not count (a later line of a multi-line statement) uses the
|
|
378
|
-
# tests that ran the statement that holds it ({StatementLines}), unless the
|
|
379
|
-
# mutant sits in code of that statement that runs only sometimes.
|
|
380
|
-
#
|
|
381
|
-
# An empty selection is `:uncapturable` (not `:no_coverage`) when the
|
|
382
|
-
# mutant's enclosing method body got coverage from no *successful* capture but
|
|
383
|
-
# a sibling test failed to capture: the coverage was lost, not absent. Both are
|
|
384
|
-
# excluded from the score denominator, so this distinction is reporting-only
|
|
385
|
-
# and never changes the daemon-vs-in-process score.
|
|
386
|
-
#
|
|
387
|
-
# @param source_file [String] the mutated source file path.
|
|
388
|
-
# @param mutation [Mutineer::Mutation] the mutation (for its line offset).
|
|
389
|
-
# @param subject [Mutineer::Subject, nil] the subject (for its method body range).
|
|
390
|
-
# @param source [String] the original source text.
|
|
391
|
-
# @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
|
|
392
|
-
# @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
|
|
393
|
-
def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
394
|
-
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
395
|
-
chosen = coverage_map.tests_for(source_file, line)
|
|
396
|
-
if chosen.empty? && subject
|
|
397
|
-
# A multi-line statement has a count on one of its lines only.
|
|
398
|
-
lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
|
|
399
|
-
chosen = lines.flat_map { |l| coverage_map.tests_for(source_file, l) }.uniq
|
|
400
|
-
end
|
|
401
|
-
if chosen.empty?
|
|
402
|
-
# Method BODY range, not the whole def: the def/end lines are "covered" at
|
|
403
|
-
# class-load even when the body never runs (body_loc is the statements' span).
|
|
404
|
-
loc = subject&.body_loc
|
|
405
|
-
range = loc ? (loc.start_line..loc.end_line) : (line..line)
|
|
406
|
-
return [:verdict, coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage]
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
[:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
# Map each line number to :all or a set of operator symbols, using
|
|
413
|
-
# inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
|
|
414
|
-
# sits on the same physical line as the code it silences). A bare marker
|
|
415
|
-
# disables every operator on that line; `disable-line a, b` only the listed
|
|
416
|
-
# operators. Block-form disable/enable ranges are intentionally not supported.
|
|
417
|
-
# Only a real `#` comment counts: Prism lists the comments, so the marker
|
|
418
|
-
# text inside a string, heredoc or regex silences nothing (#158).
|
|
419
|
-
def self.suppress_map(source, file)
|
|
420
|
-
map = {}
|
|
421
|
-
Parser.comments(source).grep(Prism::InlineComment).each do |comment|
|
|
422
|
-
line = comment.location.start_line
|
|
423
|
-
next unless (m = comment.slice.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
|
|
424
|
-
|
|
425
|
-
ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
|
|
426
|
-
# Only spaces or commas after the marker (e.g. `disable-line -- why`)
|
|
427
|
-
# is a bare marker, not an empty list that silences nothing.
|
|
428
|
-
ops = nil if ops&.empty?
|
|
429
|
-
unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
|
|
430
|
-
unknown.each do |o|
|
|
431
|
-
warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
|
|
432
|
-
"(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
|
|
433
|
-
end
|
|
434
|
-
map[line] = ops ? ops.map(&:to_sym).to_set : :all
|
|
435
|
-
end
|
|
436
|
-
map
|
|
437
|
-
end
|
|
438
|
-
|
|
439
|
-
# True when this mutant is suppressed: its line bears a disable-line marker
|
|
440
|
-
# (bare, or scoped to its operator), OR its new or old-format id is in the
|
|
441
|
-
# config ignore list. Checked at job-build time so a suppressed mutant is
|
|
442
|
-
# never forked.
|
|
443
|
-
#
|
|
444
|
-
# @param ids [Array<String>, String] the mutant's new id and its old-format
|
|
445
|
-
# id, or a single id (the pre-#126 call shape).
|
|
446
|
-
def self.suppressed?(operator, line, ids, disabled, ignore_set)
|
|
447
|
-
return true if Array(ids).any? { |id| ignore_set.include?(id) }
|
|
448
|
-
|
|
449
|
-
case (entry = disabled[line])
|
|
450
|
-
when :all then true
|
|
451
|
-
when Set then entry.include?(operator)
|
|
452
|
-
else false
|
|
453
|
-
end
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
# --since: keep only jobs whose mutation lands on a line changed since the git
|
|
457
|
-
# ref. Composes with coverage selection (it only narrows the job list; each
|
|
458
|
-
# surviving mutant still goes through Runner.run's coverage check). A file with
|
|
459
|
-
# no changed lines (absent from the diff) contributes no jobs. Line is computed
|
|
460
|
-
# exactly as Runner.run does, from the already-read source in source_map.
|
|
461
|
-
def self.filter_since(jobs, source_map, config)
|
|
462
|
-
changed = ChangedLines.for(ref: config.since, files: config.sources,
|
|
463
|
-
project_root: config.project_root)
|
|
464
|
-
jobs.select do |subject, mutation|
|
|
465
|
-
source = source_map[subject.file]
|
|
466
|
-
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
467
|
-
abs = File.expand_path(subject.file, config.project_root)
|
|
468
|
-
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
|
|
469
278
|
end
|
|
470
279
|
end
|
|
471
280
|
|
|
472
|
-
# For each test file, the directory to add to $LOAD_PATH so its
|
|
473
|
-
# `require "test_helper"` (or spec_helper) resolves: the nearest ancestor
|
|
474
|
-
# holding that helper, plus the file's own dir as a fallback.
|
|
475
|
-
def self.test_load_roots(test_files)
|
|
476
|
-
test_files.flat_map do |f|
|
|
477
|
-
dir = File.dirname(f)
|
|
478
|
-
root = nil
|
|
479
|
-
loop do
|
|
480
|
-
if File.exist?(File.join(dir, "test_helper.rb")) || File.exist?(File.join(dir, "spec_helper.rb"))
|
|
481
|
-
root = dir
|
|
482
|
-
break
|
|
483
|
-
end
|
|
484
|
-
parent = File.dirname(dir)
|
|
485
|
-
break if parent == dir
|
|
486
|
-
|
|
487
|
-
dir = parent
|
|
488
|
-
end
|
|
489
|
-
[root, File.dirname(f)].compact
|
|
490
|
-
end.uniq
|
|
491
|
-
end
|
|
492
|
-
|
|
493
281
|
# When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
|
|
494
282
|
# before the app boots. Otherwise it boots development and nothing is measured.
|
|
495
283
|
# An explicitly-set RAILS_ENV is always respected.
|
|
@@ -501,32 +289,6 @@ module Mutineer
|
|
|
501
289
|
warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
|
|
502
290
|
end
|
|
503
291
|
|
|
504
|
-
# The unique absolute directories holding the sources. Sweep target for both
|
|
505
|
-
# orphan mechanisms (in-process mutant tempfiles and external backup files),
|
|
506
|
-
# and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
|
|
507
|
-
# Shared so the path-expansion rule cannot drift between the paths.
|
|
508
|
-
#
|
|
509
|
-
# @param config [Mutineer::Config] run configuration.
|
|
510
|
-
# @return [Array<String>] unique absolute source directories.
|
|
511
|
-
def self.source_dirs(config)
|
|
512
|
-
config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
# Removes stale mutant tempfiles from the given directories. The daemon writes a
|
|
516
|
-
# differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
|
|
517
|
-
# tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
|
|
518
|
-
#
|
|
519
|
-
# @param dirs [Array<String>] directories to sweep.
|
|
520
|
-
# @param glob [String] filename pattern to remove.
|
|
521
|
-
# @return [void]
|
|
522
|
-
def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
|
|
523
|
-
dirs.each do |dir|
|
|
524
|
-
Dir.glob(File.join(dir, glob)).each do |f|
|
|
525
|
-
File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
|
|
526
|
-
end
|
|
527
|
-
end
|
|
528
|
-
end
|
|
529
|
-
|
|
530
292
|
# Runs a single mutation through isolation.
|
|
531
293
|
#
|
|
532
294
|
# @param mutation [Mutineer::Mutation] mutation to run.
|
|
@@ -552,8 +314,9 @@ module Mutineer
|
|
|
552
314
|
# no test exercises is :no_coverage (no fork); otherwise exactly the
|
|
553
315
|
# covering test files run in the child. Shared with the daemon path so both
|
|
554
316
|
# narrow identically (score parity).
|
|
555
|
-
kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
317
|
+
kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
556
318
|
return payload if kind == :verdict
|
|
319
|
+
return Result.unplaceable if strategy == "redefine" && subject&.owner_unknown
|
|
557
320
|
|
|
558
321
|
abs_tests = payload
|
|
559
322
|
|
|
@@ -574,7 +337,8 @@ module Mutineer
|
|
|
574
337
|
TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
|
|
575
338
|
end
|
|
576
339
|
end
|
|
577
|
-
relative_kills(result, coverage_map.project_root)
|
|
340
|
+
result = relative_kills(result, coverage_map.project_root)
|
|
341
|
+
JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
|
|
578
342
|
end
|
|
579
343
|
|
|
580
344
|
# Rewrites the test files of a result's {Kills} row relative to the project
|
|
@@ -41,6 +41,46 @@ module Mutineer
|
|
|
41
41
|
lines
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Whether the code at `offset` runs each time the method runs: it is in
|
|
45
|
+
# the first statement of the body, not in a statement nested in it or in
|
|
46
|
+
# a {NESTED} node (a block or loop body), and not in code that runs only
|
|
47
|
+
# sometimes ({sometimes?}, the body of `x if c`). The first statement in
|
|
48
|
+
# parentheses or `begin ... end` runs with them, so it counts
|
|
49
|
+
# ({grouped_first?}). Used for a method whose body shares the `def` line,
|
|
50
|
+
# where only the method's call count tells that the code ran (#209).
|
|
51
|
+
#
|
|
52
|
+
# @param def_node [Prism::DefNode] the method that holds the position.
|
|
53
|
+
# @param offset [Integer] byte offset of the position.
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def self.runs_with_method?(def_node, offset)
|
|
56
|
+
first = def_node.body.is_a?(Prism::StatementsNode) && def_node.body.body.first
|
|
57
|
+
path = first && path_to(first, offset)
|
|
58
|
+
return false unless path
|
|
59
|
+
|
|
60
|
+
return false if path.drop(1).any? { |node| NESTED.any? { |klass| node.is_a?(klass) } }
|
|
61
|
+
return false if path[1]&.newline?
|
|
62
|
+
|
|
63
|
+
path.each_cons(3).none? { |grand, parent, node| node.newline? && !grouped_first?(grand, parent, node) } &&
|
|
64
|
+
path.each_cons(2).none? { |parent, child| sometimes?(parent, child) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Whether `node` is the first statement in parentheses or `begin ... end`,
|
|
68
|
+
# which runs each time they do.
|
|
69
|
+
#
|
|
70
|
+
# @param grand [Prism::Node] the parent of `parent`.
|
|
71
|
+
# @param parent [Prism::Node] the parent of `node`.
|
|
72
|
+
# @param node [Prism::Node]
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def self.grouped_first?(grand, parent, node)
|
|
75
|
+
(grand.is_a?(Prism::ParenthesesNode) || grand.is_a?(Prism::BeginNode)) &&
|
|
76
|
+
parent.is_a?(Prism::StatementsNode) && parent.body.first.equal?(node)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Nodes whose code can run any number of times, or not at all, when the
|
|
80
|
+
# statement that holds them runs.
|
|
81
|
+
NESTED = [Prism::BlockNode, Prism::LambdaNode, Prism::WhileNode, Prism::UntilNode, Prism::ForNode,
|
|
82
|
+
Prism::DefNode].freeze
|
|
83
|
+
|
|
44
84
|
# Nodes whose code runs only when a branch, a match or an exception picks
|
|
45
85
|
# it: a `when` or `in` clause (its condition or pattern), a `rescue` clause
|
|
46
86
|
# (its class list), an `else` branch, parameters (their defaults) and
|
data/lib/mutineer/subject.rb
CHANGED
|
@@ -7,8 +7,12 @@ module Mutineer
|
|
|
7
7
|
# because mutators walk the def node directly. `namespace` names the owner
|
|
8
8
|
# (`module ::X` inside `Outer` owns into `X`); `lexical` is the class/module
|
|
9
9
|
# chain as written (`["Outer", "::X"]`), which the redefine strategy needs to
|
|
10
|
-
# rebuild the same Module.nesting as a whole-file reload (#145).
|
|
11
|
-
|
|
10
|
+
# rebuild the same Module.nesting as a whole-file reload (#145). `block_owner`
|
|
11
|
+
# is the constant a `Data.define`-style block is assigned to, as written;
|
|
12
|
+
# `owner_unknown` is true when the class that block builds cannot be named,
|
|
13
|
+
# so the redefine strategy cannot load a mutant onto it.
|
|
14
|
+
Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, :block_owner, :owner_unknown,
|
|
15
|
+
keyword_init: true) do
|
|
12
16
|
# Returns the fully-qualified subject name.
|
|
13
17
|
#
|
|
14
18
|
# @return [String] namespaced method name like `Billing::Invoice#total`.
|