mutineer 1.1.0 → 1.3.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 +115 -0
- data/README.md +54 -5
- data/lib/mutineer/baseline.rb +74 -11
- data/lib/mutineer/cli.rb +71 -4
- data/lib/mutineer/coverage_map.rb +37 -40
- data/lib/mutineer/daemon_backend.rb +5 -4
- data/lib/mutineer/isolation.rb +9 -4
- data/lib/mutineer/mutant_id.rb +72 -13
- data/lib/mutineer/mutator_registry.rb +9 -3
- data/lib/mutineer/mutators/array_literal.rb +51 -0
- data/lib/mutineer/mutators/base.rb +15 -0
- data/lib/mutineer/mutators/operand_removal.rb +65 -0
- data/lib/mutineer/pairing.rb +10 -5
- data/lib/mutineer/project.rb +47 -18
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/reporter.rb +18 -5
- data/lib/mutineer/result.rb +2 -2
- data/lib/mutineer/runner.rb +90 -26
- data/lib/mutineer/subject.rb +13 -2
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +2 -0
- metadata +4 -1
data/lib/mutineer/runner.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
3
4
|
require_relative "parser"
|
|
4
5
|
require_relative "project"
|
|
5
6
|
require_relative "result"
|
|
@@ -12,6 +13,7 @@ require_relative "mutator_registry"
|
|
|
12
13
|
require_relative "worker_pool"
|
|
13
14
|
require_relative "progress"
|
|
14
15
|
require_relative "mutant_id"
|
|
16
|
+
require_relative "project_path"
|
|
15
17
|
require_relative "file_swap"
|
|
16
18
|
require_relative "external_backend"
|
|
17
19
|
require_relative "daemon_backend"
|
|
@@ -30,15 +32,18 @@ module Mutineer
|
|
|
30
32
|
class Runner
|
|
31
33
|
# Full orchestration: resolve operators, discover subjects, build the
|
|
32
34
|
# coverage map, run every mutation, and aggregate. Returns
|
|
33
|
-
# [AggregateResult, source_map]
|
|
34
|
-
#
|
|
35
|
+
# [AggregateResult, source_map, extras], where extras is the hash
|
|
36
|
+
# {.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
|
|
37
|
+
# The CLI then reports + applies the exit code; the integration test asserts
|
|
38
|
+
# directly on the AggregateResult.
|
|
35
39
|
#
|
|
36
40
|
# The parent process `require`s each source file so its classes exist; forked
|
|
37
41
|
# children inherit them, so a covering test file's own require_relative of the
|
|
38
42
|
# source is a no-op and does not clobber the mutated `load` (spec §7).
|
|
39
43
|
#
|
|
40
44
|
# @param config [Mutineer::Config] run configuration.
|
|
41
|
-
# @return [Array(Mutineer::AggregateResult, Hash<String, String
|
|
45
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
|
|
46
|
+
# source map, and run extras.
|
|
42
47
|
def self.execute(config)
|
|
43
48
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
44
49
|
|
|
@@ -93,16 +98,22 @@ module Mutineer
|
|
|
93
98
|
verbose: config.verbose
|
|
94
99
|
).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
|
|
95
100
|
else
|
|
101
|
+
# 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) })
|
|
103
|
+
libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
|
|
104
|
+
$LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
|
|
105
|
+
# Relative, so the cache digest does not depend on the checkout path.
|
|
106
|
+
rel_roots = test_roots.map { |d| Pathname(d).relative_path_from(File.expand_path(config.project_root)).to_s }
|
|
96
107
|
coverage_map = CoverageMap.new(
|
|
97
108
|
source_paths: config.sources, test_paths: config.tests,
|
|
98
109
|
cache_dir: config.cache_dir, project_root: config.project_root,
|
|
99
|
-
load_paths: config.load_paths, framework: config.framework
|
|
110
|
+
load_paths: config.load_paths + rel_roots, framework: config.framework
|
|
100
111
|
).build_or_load
|
|
101
112
|
end
|
|
102
113
|
abort_if_unclean!(coverage_map)
|
|
103
114
|
|
|
104
115
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
105
|
-
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
116
|
+
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
106
117
|
|
|
107
118
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
108
119
|
|
|
@@ -137,40 +148,75 @@ module Mutineer
|
|
|
137
148
|
sweep_orphans(dirs)
|
|
138
149
|
end
|
|
139
150
|
|
|
140
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
151
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
141
152
|
end
|
|
142
153
|
|
|
143
154
|
# Collect every (subject, mutation, id) up front so a backend can run them.
|
|
144
155
|
# A mutant the user marked known-equivalent (inline disable-line comment or
|
|
145
156
|
# .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
|
|
146
157
|
# removed from the killed+survived denominator so a strong file reaches 100%.
|
|
147
|
-
# The
|
|
148
|
-
#
|
|
149
|
-
# the
|
|
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.
|
|
150
162
|
#
|
|
151
|
-
#
|
|
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).
|
|
171
|
+
#
|
|
172
|
+
# Subjects sharing a qualified name in one file (two owner-less `def index`
|
|
173
|
+
# in two DSL blocks) get a per-file ordinal in discovery order, so their ids
|
|
174
|
+
# differ; the first one's ordinal is 0 and leaves its id unchanged.
|
|
175
|
+
#
|
|
176
|
+
# @param config [Mutineer::Config] run configuration.
|
|
177
|
+
# @param operator_classes [Array<Class>] resolved operators.
|
|
178
|
+
# @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
|
|
179
|
+
# jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
|
|
152
180
|
def self.collect_jobs(config, operator_classes)
|
|
153
181
|
source_map = {}
|
|
154
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] = {} }
|
|
155
188
|
ignore_set = config.ignore.to_set
|
|
156
189
|
jobs = []
|
|
157
190
|
ignored_results = []
|
|
191
|
+
legacy_ignore_matches = {}
|
|
192
|
+
id_map = {}
|
|
158
193
|
Project.discover(config.sources, only: config.only).each do |subject|
|
|
159
194
|
source = (source_map[subject.file] ||= File.read(subject.file))
|
|
160
|
-
disabled = (disabled_map[subject.file] ||= suppress_map(source))
|
|
195
|
+
disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
|
|
161
196
|
mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
|
|
162
|
-
|
|
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)
|
|
163
202
|
mutations.each_with_index do |mutation, i|
|
|
164
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
|
|
165
211
|
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
166
|
-
if suppressed?(mutation.operator, line, id, disabled, ignore_set)
|
|
212
|
+
if suppressed?(mutation.operator, line, [id, legacy], disabled, ignore_set)
|
|
167
213
|
ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
|
|
168
214
|
else
|
|
169
215
|
jobs << [subject, mutation, id]
|
|
170
216
|
end
|
|
171
217
|
end
|
|
172
218
|
end
|
|
173
|
-
[jobs, ignored_results, source_map]
|
|
219
|
+
[jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
|
|
174
220
|
end
|
|
175
221
|
|
|
176
222
|
# External backend orchestration. Runs each mutant's whole-file mutation on
|
|
@@ -182,7 +228,8 @@ module Mutineer
|
|
|
182
228
|
#
|
|
183
229
|
# @param config [Mutineer::Config] run configuration (test_command set).
|
|
184
230
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
185
|
-
# @return [Array(Mutineer::AggregateResult, Hash<String,String
|
|
231
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
232
|
+
# source map, and the {.collect_jobs} extras.
|
|
186
233
|
def self.execute_external(config, operator_classes)
|
|
187
234
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
188
235
|
sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
|
|
@@ -198,12 +245,12 @@ module Mutineer
|
|
|
198
245
|
# source. Heal first, then discover jobs from the clean tree.
|
|
199
246
|
FileSwap.restore_orphans(dirs)
|
|
200
247
|
|
|
201
|
-
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
248
|
+
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
202
249
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
203
250
|
|
|
204
251
|
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
205
252
|
# --test set to calibrate a timeout no mutant would use (#76).
|
|
206
|
-
next [AggregateResult.new(ignored_results), source_map] if jobs.empty?
|
|
253
|
+
next [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
|
|
207
254
|
|
|
208
255
|
# Calibrate the per-mutant timeout from the clean run (a real suite far
|
|
209
256
|
# outlasts the 10s in-process fork budget), and abort if it is not green.
|
|
@@ -227,7 +274,7 @@ module Mutineer
|
|
|
227
274
|
FileSwap.restore_orphans(dirs)
|
|
228
275
|
end
|
|
229
276
|
|
|
230
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
277
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
231
278
|
end
|
|
232
279
|
end
|
|
233
280
|
|
|
@@ -253,8 +300,13 @@ module Mutineer
|
|
|
253
300
|
#
|
|
254
301
|
# @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
|
|
255
302
|
# @return [void]
|
|
256
|
-
# @raise [Mutineer::SmokeCheckError] when any captured test failed clean
|
|
303
|
+
# @raise [Mutineer::SmokeCheckError] when any captured test failed clean,
|
|
304
|
+
# or when no test recorded coverage and a capture failed.
|
|
257
305
|
def self.abort_if_unclean!(coverage_map)
|
|
306
|
+
if coverage_map.map.empty? && coverage_map.failed_test_files.any?
|
|
307
|
+
raise SmokeCheckError, "no test recorded coverage, and capture failed for #{coverage_map.failed_test_files.join(', ')}"
|
|
308
|
+
end
|
|
309
|
+
|
|
258
310
|
files = coverage_map.failed_clean_tests
|
|
259
311
|
return if files.empty?
|
|
260
312
|
|
|
@@ -299,22 +351,34 @@ module Mutineer
|
|
|
299
351
|
# sits on the same physical line as the code it silences). A bare marker
|
|
300
352
|
# disables every operator on that line; `disable-line a, b` only the listed
|
|
301
353
|
# operators. Block-form disable/enable ranges are intentionally not supported.
|
|
302
|
-
def self.suppress_map(source)
|
|
354
|
+
def self.suppress_map(source, file)
|
|
303
355
|
map = {}
|
|
304
356
|
source.each_line.with_index(1) do |text, line|
|
|
305
357
|
next unless (m = text.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
|
|
306
358
|
|
|
307
|
-
ops = m[1]
|
|
308
|
-
|
|
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
|
|
309
369
|
end
|
|
310
370
|
map
|
|
311
371
|
end
|
|
312
372
|
|
|
313
373
|
# True when this mutant is suppressed: its line bears a disable-line marker
|
|
314
|
-
# (bare, or scoped to its operator), OR its
|
|
315
|
-
# list. Checked at job-build time so a suppressed mutant is
|
|
316
|
-
|
|
317
|
-
|
|
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) }
|
|
318
382
|
|
|
319
383
|
case (entry = disabled[line])
|
|
320
384
|
when :all then true
|
data/lib/mutineer/subject.rb
CHANGED
|
@@ -4,8 +4,11 @@ module Mutineer
|
|
|
4
4
|
# One discoverable method and its AST node.
|
|
5
5
|
#
|
|
6
6
|
# Location, namespace context, and the live Prism::DefNode are kept together
|
|
7
|
-
# because mutators walk the def node directly.
|
|
8
|
-
|
|
7
|
+
# because mutators walk the def node directly. `namespace` names the owner
|
|
8
|
+
# (`module ::X` inside `Outer` owns into `X`); `lexical` is the class/module
|
|
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
|
+
Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, keyword_init: true) do
|
|
9
12
|
# Returns the fully-qualified subject name.
|
|
10
13
|
#
|
|
11
14
|
# @return [String] namespaced method name like `Billing::Invoice#total`.
|
|
@@ -13,6 +16,14 @@ module Mutineer
|
|
|
13
16
|
namespace.join("::") + (singleton ? "." : "#") + name.to_s
|
|
14
17
|
end
|
|
15
18
|
|
|
19
|
+
# Class/module chain as written in the source, for textual wrappers. Falls
|
|
20
|
+
# back to `namespace` for subjects built without one.
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<String>] names; a root-anchored element keeps its `::`.
|
|
23
|
+
def lexical_namespace
|
|
24
|
+
lexical || namespace
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
# Returns the body location for the subject, if any.
|
|
17
28
|
#
|
|
18
29
|
# @return [Prism::Location, nil] body location or nil for empty methods.
|
data/lib/mutineer/version.rb
CHANGED
data/lib/mutineer.rb
CHANGED
|
@@ -29,6 +29,8 @@ require_relative "mutineer/mutators/safe_navigation"
|
|
|
29
29
|
require_relative "mutineer/mutators/range_literal"
|
|
30
30
|
require_relative "mutineer/mutators/negation_removal"
|
|
31
31
|
require_relative "mutineer/mutators/chain_link"
|
|
32
|
+
require_relative "mutineer/mutators/operand_removal"
|
|
33
|
+
require_relative "mutineer/mutators/array_literal"
|
|
32
34
|
require_relative "mutineer/mutator_registry"
|
|
33
35
|
require_relative "mutineer/worker_pool"
|
|
34
36
|
require_relative "mutineer/progress"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutineer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- lib/mutineer/mutation.rb
|
|
71
71
|
- lib/mutineer/mutator_registry.rb
|
|
72
72
|
- lib/mutineer/mutators/arithmetic.rb
|
|
73
|
+
- lib/mutineer/mutators/array_literal.rb
|
|
73
74
|
- lib/mutineer/mutators/base.rb
|
|
74
75
|
- lib/mutineer/mutators/boolean_connector.rb
|
|
75
76
|
- lib/mutineer/mutators/boolean_literal.rb
|
|
@@ -79,6 +80,7 @@ files:
|
|
|
79
80
|
- lib/mutineer/mutators/condition_negation.rb
|
|
80
81
|
- lib/mutineer/mutators/literal_mutation.rb
|
|
81
82
|
- lib/mutineer/mutators/negation_removal.rb
|
|
83
|
+
- lib/mutineer/mutators/operand_removal.rb
|
|
82
84
|
- lib/mutineer/mutators/range_literal.rb
|
|
83
85
|
- lib/mutineer/mutators/regex_literal.rb
|
|
84
86
|
- lib/mutineer/mutators/return_nil.rb
|
|
@@ -89,6 +91,7 @@ files:
|
|
|
89
91
|
- lib/mutineer/parser.rb
|
|
90
92
|
- lib/mutineer/progress.rb
|
|
91
93
|
- lib/mutineer/project.rb
|
|
94
|
+
- lib/mutineer/project_path.rb
|
|
92
95
|
- lib/mutineer/rails_worker_db.rb
|
|
93
96
|
- lib/mutineer/reporter.rb
|
|
94
97
|
- lib/mutineer/result.rb
|