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,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Immutable outcome of running one mutant. Ten distinct states:
|
|
5
|
+
# killed - a test failed/errored, so the mutation was caught.
|
|
6
|
+
# survived - every test passed, so the mutation went undetected.
|
|
7
|
+
# error - the child crashed (unhandled exception): exit status 2.
|
|
8
|
+
# timeout - the parent SIGKILLed a child that overran its wall clock.
|
|
9
|
+
# skipped - the mutated source failed to re-parse (invalid); no fork.
|
|
10
|
+
# no_coverage - no test exercises the mutated line; not run, not scored.
|
|
11
|
+
# uncapturable - the line's would-be covering test errored during capture,
|
|
12
|
+
# so coverage was lost. Excluded from the denominator exactly
|
|
13
|
+
# like no_coverage, but reported separately: it signals a
|
|
14
|
+
# broken harness (a test that failed to run), not a genuine
|
|
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.
|
|
29
|
+
# ignored - a known-equivalent mutant the user suppressed, via an
|
|
30
|
+
# inline `# mutineer:disable-line` comment or a
|
|
31
|
+
# `.mutineer.yml` `ignore:` id. A pre-fork classification
|
|
32
|
+
# (never run); excluded from the denominator so a strong
|
|
33
|
+
# file can reach 100%.
|
|
34
|
+
#
|
|
35
|
+
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
36
|
+
# validity failure (counted separately by the reporter), error is a runtime
|
|
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
|
|
40
|
+
# denominator.
|
|
41
|
+
#
|
|
42
|
+
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
43
|
+
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
44
|
+
# them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
45
|
+
# can render survivor diffs and emit the id. `id` is the content-based
|
|
46
|
+
# MutantId (it includes the project-relative file path).
|
|
47
|
+
#
|
|
48
|
+
# `kills` is nil except in a `--matrix` run, where a mutant that was forked
|
|
49
|
+
# carries the {Kills} its child reported. It annotates the verdict and never
|
|
50
|
+
# decides it.
|
|
51
|
+
#
|
|
52
|
+
# A Result crosses a process boundary only through WorkerPool's Marshal pipe,
|
|
53
|
+
# from a fork back to the process that forked it, so both ends always run the
|
|
54
|
+
# same code. Nothing persists a marshaled Result (reports, baselines and the
|
|
55
|
+
# coverage cache are JSON), so a new field needs no Marshal compatibility.
|
|
56
|
+
Result = Data.define(:status, :details, :subject, :mutation, :id, :kills) do
|
|
57
|
+
# Every field but `status` defaults to nil.
|
|
58
|
+
#
|
|
59
|
+
# @param status [Symbol] the outcome.
|
|
60
|
+
# @param details [String, nil] error or skip details.
|
|
61
|
+
# @param subject [Mutineer::Subject, nil] the mutated subject.
|
|
62
|
+
# @param mutation [Mutineer::Mutation, nil] the mutation.
|
|
63
|
+
# @param id [String, nil] the stable mutant id.
|
|
64
|
+
# @param kills [Mutineer::Kills, nil] the matrix row of a `--matrix` run.
|
|
65
|
+
def initialize(status:, details: nil, subject: nil, mutation: nil, id: nil, kills: nil)
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Builds a killed result.
|
|
70
|
+
#
|
|
71
|
+
# @return [Mutineer::Result] killed result.
|
|
72
|
+
def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
|
|
73
|
+
|
|
74
|
+
# Builds a survived result.
|
|
75
|
+
#
|
|
76
|
+
# @return [Mutineer::Result] survived result.
|
|
77
|
+
def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
|
|
78
|
+
|
|
79
|
+
# Builds an error result.
|
|
80
|
+
#
|
|
81
|
+
# @param details [String, nil] error details.
|
|
82
|
+
# @return [Mutineer::Result] error result.
|
|
83
|
+
def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil, id: nil)
|
|
84
|
+
|
|
85
|
+
# Builds a timeout result.
|
|
86
|
+
#
|
|
87
|
+
# @return [Mutineer::Result] timeout result.
|
|
88
|
+
def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
|
|
89
|
+
|
|
90
|
+
# Builds a skipped result.
|
|
91
|
+
#
|
|
92
|
+
# @param details [String, nil] skip details.
|
|
93
|
+
# @return [Mutineer::Result] skipped result.
|
|
94
|
+
def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil, id: nil)
|
|
95
|
+
|
|
96
|
+
# Builds a no_coverage result.
|
|
97
|
+
#
|
|
98
|
+
# @return [Mutineer::Result] no-coverage result.
|
|
99
|
+
def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
|
|
100
|
+
|
|
101
|
+
# Builds an uncapturable result.
|
|
102
|
+
#
|
|
103
|
+
# @return [Mutineer::Result] uncapturable result.
|
|
104
|
+
def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
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
|
+
|
|
116
|
+
# Builds an ignored result.
|
|
117
|
+
#
|
|
118
|
+
# @return [Mutineer::Result] ignored result.
|
|
119
|
+
def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
|
|
120
|
+
|
|
121
|
+
# @return [Boolean] true when the status is killed.
|
|
122
|
+
def killed? = status == :killed
|
|
123
|
+
# @return [Boolean] true when the status is survived.
|
|
124
|
+
def survived? = status == :survived
|
|
125
|
+
# @return [Boolean] true when the status is error.
|
|
126
|
+
def error? = status == :error
|
|
127
|
+
# @return [Boolean] true when the status is timeout.
|
|
128
|
+
def timeout? = status == :timeout
|
|
129
|
+
# @return [Boolean] true when the status is skipped.
|
|
130
|
+
def skipped? = status == :skipped
|
|
131
|
+
# @return [Boolean] true when the status is no_coverage.
|
|
132
|
+
def no_coverage? = status == :no_coverage
|
|
133
|
+
# @return [Boolean] true when the status is uncapturable.
|
|
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
|
|
139
|
+
# @return [Boolean] true when the status is ignored.
|
|
140
|
+
def ignored? = status == :ignored
|
|
141
|
+
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# One mutant's row of the kill matrix (`--matrix`): which tests killed it and
|
|
145
|
+
# which ran against it. A test is a `[file, name, id]` triple: the
|
|
146
|
+
# project-relative file that defines it, its name (`CalculatorTest#test_add`,
|
|
147
|
+
# or an RSpec example's full description), and an id that tells apart tests
|
|
148
|
+
# sharing a file and name (an RSpec example id; the name again for Minitest).
|
|
149
|
+
# File and id identify a test; the name is display data that a mutant can
|
|
150
|
+
# change. Both lists are sorted and unique, and `ran` includes the killers.
|
|
151
|
+
#
|
|
152
|
+
# `complete` is true when the child ran its whole suite and reported it
|
|
153
|
+
# cleanly (see Isolation.finish). It is false when the child timed out,
|
|
154
|
+
# errored or exited the process early, when the recorder never armed, when
|
|
155
|
+
# its lines arrived out of order, when Minitest returned after an Interrupt
|
|
156
|
+
# with tests unseen, or when the row does not agree with the verdict.
|
|
157
|
+
Kills = Data.define(:killed_by, :ran, :complete)
|
|
158
|
+
|
|
159
|
+
# Aggregates a flat list of Results into counts, the mutation score, and the
|
|
160
|
+
# surviving-mutant list. The score denominator is killed + survived ONLY:
|
|
161
|
+
# no-coverage, uncapturable, unplaceable, ran-at-load, skipped (invalid), errored, timeout, and ignored
|
|
162
|
+
# (equivalent-mutant suppression) are each excluded and surfaced separately,
|
|
163
|
+
# so suppressing every survivor reaches 100%. An empty denominator yields a
|
|
164
|
+
# nil score (rendered "N/A"), never 0.0, distinguishing "no testable mutants"
|
|
165
|
+
# from "0% killed".
|
|
166
|
+
class AggregateResult
|
|
167
|
+
attr_reader :results
|
|
168
|
+
|
|
169
|
+
# Builds an aggregate from results.
|
|
170
|
+
#
|
|
171
|
+
# @param results [Array<Mutineer::Result>] classified results.
|
|
172
|
+
def initialize(results)
|
|
173
|
+
@results = results
|
|
174
|
+
@by_status = results.group_by(&:status)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @return [Integer] killed count.
|
|
178
|
+
def killed_count = count(:killed)
|
|
179
|
+
# @return [Integer] survived count.
|
|
180
|
+
def survived_count = count(:survived)
|
|
181
|
+
# @return [Integer] no-coverage count.
|
|
182
|
+
def no_coverage_count = count(:no_coverage)
|
|
183
|
+
# @return [Integer] uncapturable count.
|
|
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)
|
|
189
|
+
# @return [Integer] skipped-invalid count.
|
|
190
|
+
def skipped_invalid_count = count(:skipped)
|
|
191
|
+
# @return [Integer] errored count.
|
|
192
|
+
def errored_count = count(:error)
|
|
193
|
+
# @return [Integer] timeout count.
|
|
194
|
+
def timeout_count = count(:timeout)
|
|
195
|
+
# @return [Integer] ignored count.
|
|
196
|
+
def ignored_count = count(:ignored)
|
|
197
|
+
|
|
198
|
+
# Every generated, classified mutation. NOT the score denominator.
|
|
199
|
+
#
|
|
200
|
+
# @return [Integer] total result count.
|
|
201
|
+
def total = @results.size
|
|
202
|
+
|
|
203
|
+
# The score denominator (also shown to the reader).
|
|
204
|
+
#
|
|
205
|
+
# @return [Integer] killed plus survived.
|
|
206
|
+
def covered_count = killed_count + survived_count
|
|
207
|
+
|
|
208
|
+
# Computes the mutation score.
|
|
209
|
+
#
|
|
210
|
+
# @return [Float, nil] score percentage or nil when nothing was testable.
|
|
211
|
+
def mutation_score
|
|
212
|
+
return nil if covered_count.zero?
|
|
213
|
+
|
|
214
|
+
(killed_count.to_f / covered_count * 100).round(1)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Returns the surviving mutants.
|
|
218
|
+
#
|
|
219
|
+
# @return [Array<Mutineer::Result>] surviving results.
|
|
220
|
+
def surviving_mutants = @results.select(&:survived?)
|
|
221
|
+
|
|
222
|
+
# Groups results by source file so the Reporter (per-source breakdown) and
|
|
223
|
+
# baseline diff can reuse the same aggregate math.
|
|
224
|
+
#
|
|
225
|
+
# @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
|
|
226
|
+
def by_source
|
|
227
|
+
@results.select { |r| r.subject }
|
|
228
|
+
.group_by { |r| r.subject.file }
|
|
229
|
+
.transform_values { |rs| AggregateResult.new(rs) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
# Counts results for a status.
|
|
235
|
+
#
|
|
236
|
+
# @param status [Symbol] result status.
|
|
237
|
+
# @return [Integer] count for that status.
|
|
238
|
+
def count(status) = (@by_status[status] || []).size
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "parser"
|
|
5
|
+
require_relative "project"
|
|
6
|
+
require_relative "result"
|
|
7
|
+
require_relative "isolation"
|
|
8
|
+
require_relative "minitest_integration"
|
|
9
|
+
require_relative "test_runners"
|
|
10
|
+
require_relative "coverage_map"
|
|
11
|
+
require_relative "mutator_registry"
|
|
12
|
+
require_relative "worker_pool"
|
|
13
|
+
require_relative "progress"
|
|
14
|
+
require_relative "project_path"
|
|
15
|
+
require_relative "file_swap"
|
|
16
|
+
require_relative "external_backend"
|
|
17
|
+
require_relative "daemon_backend"
|
|
18
|
+
require_relative "job_plan"
|
|
19
|
+
|
|
20
|
+
module Mutineer
|
|
21
|
+
# Orchestrates one mutation end-to-end: apply it textually, validate the
|
|
22
|
+
# result, select its covering test files from the coverage map, then run only
|
|
23
|
+
# those against the mutated source in an isolated child process (strategy
|
|
24
|
+
# whole-file reload via `load`).
|
|
25
|
+
#
|
|
26
|
+
# The source file path is passed explicitly because Mutation carries only byte
|
|
27
|
+
# offsets, not its file. Coverage-map selection replaces a hardcoded test file:
|
|
28
|
+
# a mutation whose line no test exercises is :no_coverage (no fork); otherwise
|
|
29
|
+
# exactly the covering test files run in the child.
|
|
30
|
+
class Runner
|
|
31
|
+
# Full orchestration: resolve operators, discover subjects, build the
|
|
32
|
+
# coverage map, run every mutation, and aggregate. Returns
|
|
33
|
+
# [AggregateResult, source_map, extras], where extras is the hash
|
|
34
|
+
# {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
|
|
35
|
+
# The CLI then reports + applies the exit code; the integration test asserts
|
|
36
|
+
# directly on the AggregateResult.
|
|
37
|
+
#
|
|
38
|
+
# The parent process `require`s each source file so its classes exist; forked
|
|
39
|
+
# children inherit them, so a covering test file's own require_relative of the
|
|
40
|
+
# source is a no-op and does not clobber the mutated `load` (spec §7).
|
|
41
|
+
#
|
|
42
|
+
# @param config [Mutineer::Config] run configuration.
|
|
43
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
|
|
44
|
+
# source map, and run extras.
|
|
45
|
+
def self.execute(config)
|
|
46
|
+
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
47
|
+
|
|
48
|
+
# External backend: run the suite as a subprocess in the app's own runtime.
|
|
49
|
+
# It does no in-process boot/require or coverage build, so branch before any
|
|
50
|
+
# of that. The in-process path below is untouched.
|
|
51
|
+
return execute_external(config, operator_classes) if config.test_command
|
|
52
|
+
|
|
53
|
+
# Daemon backend: boot the app ONCE in a persistent subprocess under the
|
|
54
|
+
# app's bundle and fork per mutant. Tool-side we only discover jobs + build
|
|
55
|
+
# payloads (Prism), so branch before any in-process boot.
|
|
56
|
+
return DaemonBackend.execute(config, operator_classes) if config.daemon
|
|
57
|
+
|
|
58
|
+
# Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
|
|
59
|
+
# in the parent and inherited by every fork. Do NOT manually require the
|
|
60
|
+
# sources. Under Zeitwerk a manual require of an autoloadable file raises;
|
|
61
|
+
# the booted env autoloads them, and subject discovery is a static Prism
|
|
62
|
+
# parse that needs nothing loaded. Standalone mode requires the sources as
|
|
63
|
+
# before so their classes exist for the children to inherit.
|
|
64
|
+
if config.boot
|
|
65
|
+
# Under --rails an unset RAILS_ENV boots development, where the test
|
|
66
|
+
# suite is not loaded. Coverage comes back empty and EVERY mutant is
|
|
67
|
+
# falsely reported no_coverage (score N/A, exit 0). Default it to test.
|
|
68
|
+
ensure_rails_env(config)
|
|
69
|
+
|
|
70
|
+
# Coverage instruments only files loaded AFTER it starts. Start it BEFORE
|
|
71
|
+
# the boot require so the entire app loaded during boot is instrumented;
|
|
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
|
+
require "coverage"
|
|
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
|
|
81
|
+
require File.expand_path(config.boot, config.project_root)
|
|
82
|
+
else
|
|
83
|
+
config.sources.each { |f| require File.expand_path(f, config.project_root) }
|
|
84
|
+
end
|
|
85
|
+
config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
|
|
86
|
+
preload_owners(config) if config.boot && config.strategy == "redefine"
|
|
87
|
+
|
|
88
|
+
if config.boot
|
|
89
|
+
# Rails/Minitest test files do `require "test_helper"`, which needs the
|
|
90
|
+
# test root on $LOAD_PATH (`bin/rails test` adds it). Prepend each test
|
|
91
|
+
# file's helper root here in the parent so loading them in the fork
|
|
92
|
+
# children (both coverage capture and per-mutant) resolves.
|
|
93
|
+
boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
94
|
+
JobPlan.test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
|
|
95
|
+
|
|
96
|
+
# Boot mode now uses coverage selection too: capture each test's coverage
|
|
97
|
+
# by forking the booted parent, then select covering tests per mutant.
|
|
98
|
+
coverage_map = CoverageMap.new(
|
|
99
|
+
source_paths: config.sources, test_paths: config.tests,
|
|
100
|
+
cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
|
|
101
|
+
load_paths: config.load_paths, framework: config.framework,
|
|
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
|
|
104
|
+
verbose: config.verbose,
|
|
105
|
+
capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
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?
|
|
110
|
+
else
|
|
111
|
+
# As in boot mode, and with lib first as `rake test` does.
|
|
112
|
+
test_roots = JobPlan.test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
|
|
113
|
+
libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
|
|
114
|
+
$LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
|
|
115
|
+
# Relative, so the cache digest does not depend on the checkout path.
|
|
116
|
+
rel_roots = test_roots.map { |d| Pathname(d).relative_path_from(File.expand_path(config.project_root)).to_s }
|
|
117
|
+
coverage_map = CoverageMap.new(
|
|
118
|
+
source_paths: config.sources, test_paths: config.tests,
|
|
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
|
|
123
|
+
).build_or_load
|
|
124
|
+
end
|
|
125
|
+
JobPlan.abort_if_unclean!(coverage_map)
|
|
126
|
+
|
|
127
|
+
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
128
|
+
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
129
|
+
|
|
130
|
+
jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
|
|
131
|
+
|
|
132
|
+
# Whole-file reload writes mutineer_mutant*.rb into each source dir (so
|
|
133
|
+
# require_relative resolves). A SIGKILL'd child skips the tempfile's
|
|
134
|
+
# ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
|
|
135
|
+
# PARENT sweeps each source dir before and after the run. Orphans are
|
|
136
|
+
# impossible after a normal run.
|
|
137
|
+
dirs = JobPlan.source_dirs(config)
|
|
138
|
+
JobPlan.sweep_orphans(dirs)
|
|
139
|
+
|
|
140
|
+
strategy = config.strategy
|
|
141
|
+
# Fail-fast must be serial: a parallel stop_when fires on the first survivor
|
|
142
|
+
# by wall-clock, not input order, so the survivor set would diverge from
|
|
143
|
+
# --jobs 1 (daemon already forces serial for the same reason).
|
|
144
|
+
jobs_n = config.fail_fast ? 1 : config.jobs
|
|
145
|
+
results =
|
|
146
|
+
begin
|
|
147
|
+
framework = config.framework
|
|
148
|
+
stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
|
|
149
|
+
progress = Progress.new(jobs.size)
|
|
150
|
+
bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when,
|
|
151
|
+
on_result: ->(_r) { progress.tick }) do |subject, mutation|
|
|
152
|
+
run(mutation, source_file: subject.file, coverage_map: coverage_map,
|
|
153
|
+
subject: subject, strategy: strategy, rails: config.rails, framework: framework,
|
|
154
|
+
timeout: config.timeout || Isolation::DEFAULT_TIMEOUT, matrix: config.matrix)
|
|
155
|
+
end
|
|
156
|
+
# The bare Results carry only status (Subjects hold live AST nodes that
|
|
157
|
+
# do not marshal); reattach subject+mutation+id in the parent, in order.
|
|
158
|
+
# filter_map drops nils for jobs --fail-fast left unscheduled.
|
|
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)
|
|
163
|
+
ensure
|
|
164
|
+
JobPlan.sweep_orphans(dirs)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
168
|
+
end
|
|
169
|
+
|
|
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.
|
|
173
|
+
#
|
|
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))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# External backend orchestration. Runs each mutant's whole-file mutation on
|
|
183
|
+
# disk (crash-safe swap) and executes the user's --test-command as a subprocess
|
|
184
|
+
# in the app's own runtime. Serial by construction (one shared DB, no
|
|
185
|
+
# per-worker isolation yet). No coverage narrowing: every mutant runs the full
|
|
186
|
+
# --test set; the score is therefore an upper bound and not comparable to an
|
|
187
|
+
# in-process run (the CLI discloses this).
|
|
188
|
+
#
|
|
189
|
+
# @param config [Mutineer::Config] run configuration (test_command set).
|
|
190
|
+
# @param operator_classes [Array<Class>] resolved operators.
|
|
191
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
192
|
+
# source map, and the {JobPlan.collect_jobs} extras.
|
|
193
|
+
def self.execute_external(config, operator_classes)
|
|
194
|
+
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
195
|
+
sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
|
|
196
|
+
dirs = sources.map { |s| File.dirname(s) }.uniq
|
|
197
|
+
|
|
198
|
+
# Own every source before healing leftovers or reading bytes for mutation.
|
|
199
|
+
# A backup file alone is not ownership; flock is. Canonical paths so
|
|
200
|
+
# symlink aliases of one inode share one lock, independent of cache_dir.
|
|
201
|
+
FileSwap.owning(sources) do
|
|
202
|
+
# Heal any file a prior hard-killed run left mutated BEFORE reading source.
|
|
203
|
+
# collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
|
|
204
|
+
# still-mutated file would yield garbage offsets against the later-healed
|
|
205
|
+
# source. Heal first, then discover jobs from the clean tree.
|
|
206
|
+
FileSwap.restore_orphans(dirs)
|
|
207
|
+
|
|
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
|
|
210
|
+
|
|
211
|
+
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
212
|
+
# --test set to calibrate a timeout no mutant would use (#76).
|
|
213
|
+
next [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
|
|
214
|
+
|
|
215
|
+
# Calibrate the per-mutant timeout from the clean run (a real suite far
|
|
216
|
+
# outlasts the 10s in-process fork budget), and abort if it is not green.
|
|
217
|
+
# 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
|
|
218
|
+
# a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
|
|
219
|
+
# handful cannot stall a serial run for ~45min on a slow suite.
|
|
220
|
+
smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
|
|
221
|
+
timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
|
|
222
|
+
|
|
223
|
+
results = []
|
|
224
|
+
progress = Progress.new(jobs.size)
|
|
225
|
+
begin
|
|
226
|
+
jobs.each do |subject, mutation, id|
|
|
227
|
+
r = run_external(subject, mutation, config.test_command, abs_tests,
|
|
228
|
+
timeout: timeout, verbose: config.verbose)
|
|
229
|
+
results << r.with(subject: subject, mutation: mutation, id: id)
|
|
230
|
+
progress.tick
|
|
231
|
+
break if config.fail_fast && r.survived? # stop at the first survivor
|
|
232
|
+
end
|
|
233
|
+
ensure
|
|
234
|
+
FileSwap.restore_orphans(dirs)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Runs one mutant through the external backend: apply the whole-file mutation
|
|
242
|
+
# on disk, run the command, restore. An invalid (non-reparsing) mutant would
|
|
243
|
+
# fail to load and score a false `killed`, so skip it tool-side (Prism, already
|
|
244
|
+
# cheap) and never write the file, preserving the `skipped` verdict the
|
|
245
|
+
# in-process path gives at the pre-fork check.
|
|
246
|
+
#
|
|
247
|
+
# @return [Mutineer::Result] verdict for this mutant.
|
|
248
|
+
def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:)
|
|
249
|
+
source = File.read(subject.file)
|
|
250
|
+
mutated = mutation.apply(source)
|
|
251
|
+
return Result.skipped if Parser.parse_string(mutated).errors.any?
|
|
252
|
+
|
|
253
|
+
FileSwap.with(subject.file, mutated) do
|
|
254
|
+
ExternalBackend.run(command, abs_tests, timeout: timeout, verbose: verbose)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
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.
|
|
265
|
+
#
|
|
266
|
+
# @param config [Mutineer::Config] run configuration.
|
|
267
|
+
# @return [void]
|
|
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
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
|
|
282
|
+
# before the app boots. Otherwise it boots development and nothing is measured.
|
|
283
|
+
# An explicitly-set RAILS_ENV is always respected.
|
|
284
|
+
def self.ensure_rails_env(config)
|
|
285
|
+
return unless config.rails
|
|
286
|
+
return unless ENV["RAILS_ENV"].nil? || ENV["RAILS_ENV"].empty?
|
|
287
|
+
|
|
288
|
+
ENV["RAILS_ENV"] = "test"
|
|
289
|
+
warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Runs a single mutation through isolation.
|
|
293
|
+
#
|
|
294
|
+
# @param mutation [Mutineer::Mutation] mutation to run.
|
|
295
|
+
# @param source_file [String] source file path.
|
|
296
|
+
# @param coverage_map [Mutineer::CoverageMap, nil] coverage map.
|
|
297
|
+
# @param subject [Mutineer::Subject, nil] subject for surgical strategy.
|
|
298
|
+
# @param strategy [String] mutation strategy.
|
|
299
|
+
# @param timeout [Integer] child timeout in seconds.
|
|
300
|
+
# @param rails [Boolean] whether Rails reconnect handling is enabled.
|
|
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.
|
|
304
|
+
# @return [Mutineer::Result] mutant result.
|
|
305
|
+
def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
|
|
306
|
+
timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest", matrix: false)
|
|
307
|
+
source = File.read(source_file)
|
|
308
|
+
mutated = mutation.apply(source)
|
|
309
|
+
|
|
310
|
+
# Validity rule: a mutant that does not re-parse is skipped before forking.
|
|
311
|
+
return Result.skipped if Parser.parse_string(mutated).errors.any?
|
|
312
|
+
|
|
313
|
+
# Coverage selection (both standalone and boot mode): a mutation on a line
|
|
314
|
+
# no test exercises is :no_coverage (no fork); otherwise exactly the
|
|
315
|
+
# covering test files run in the child. Shared with the daemon path so both
|
|
316
|
+
# narrow identically (score parity).
|
|
317
|
+
kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
318
|
+
return payload if kind == :verdict
|
|
319
|
+
return Result.unplaceable if strategy == "redefine" && subject&.owner_unknown
|
|
320
|
+
|
|
321
|
+
abs_tests = payload
|
|
322
|
+
|
|
323
|
+
result = Isolation.run(timeout: timeout, channel: matrix) do |channel|
|
|
324
|
+
# Forking inherits the parent's live DB connection; sharing one socket
|
|
325
|
+
# across processes corrupts it. Drop it so AR reconnects per child.
|
|
326
|
+
reconnect_active_record if rails
|
|
327
|
+
if strategy == "redefine"
|
|
328
|
+
Isolation.apply_surgical(mutation, subject, source)
|
|
329
|
+
else
|
|
330
|
+
Isolation.apply_whole_file(mutated, source_file)
|
|
331
|
+
end
|
|
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)))
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Reconnects ActiveRecord in a forked child when available.
|
|
388
|
+
#
|
|
389
|
+
# @api private
|
|
390
|
+
# @return [void]
|
|
391
|
+
def self.reconnect_active_record
|
|
392
|
+
return unless defined?(ActiveRecord::Base)
|
|
393
|
+
|
|
394
|
+
base = ActiveRecord::Base
|
|
395
|
+
# Clearing connections here drops an open transactional-fixture
|
|
396
|
+
# transaction, so the test loses its fixture rows and fails. Skip the clear
|
|
397
|
+
# when a transaction is open; otherwise clear (per-fork write-safety).
|
|
398
|
+
return if fixture_transaction_open?(base)
|
|
399
|
+
|
|
400
|
+
base.connection_handler.clear_all_connections!
|
|
401
|
+
rescue StandardError
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
private_class_method :reconnect_active_record
|
|
405
|
+
|
|
406
|
+
# Pure, injectable predicate: true when a transactional-fixture transaction is
|
|
407
|
+
# already open on the connection. Keys off open_transactions so it is correct
|
|
408
|
+
# whenever the transaction exists, regardless of when it opened. Any probe
|
|
409
|
+
# error degrades safe to false -> caller clears (existing behaviour).
|
|
410
|
+
def self.fixture_transaction_open?(base)
|
|
411
|
+
pool = base.connection_pool
|
|
412
|
+
pool.active_connection? && base.connection.open_transactions.positive?
|
|
413
|
+
rescue StandardError
|
|
414
|
+
false
|
|
415
|
+
end
|
|
416
|
+
private_class_method :fixture_transaction_open?
|
|
417
|
+
end
|
|
418
|
+
end
|