mutineer 1.2.0 → 1.4.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 +133 -0
- data/README.md +53 -4
- data/lib/mutineer/baseline.rb +74 -11
- data/lib/mutineer/changed_lines.rb +34 -2
- data/lib/mutineer/cli.rb +102 -81
- data/lib/mutineer/config.rb +157 -49
- data/lib/mutineer/coverage_map.rb +7 -30
- data/lib/mutineer/daemon_backend.rb +5 -4
- data/lib/mutineer/isolation.rb +8 -3
- data/lib/mutineer/mutant_id.rb +72 -13
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/condition_forcing.rb +141 -0
- data/lib/mutineer/mutators/operator_assignment.rb +120 -0
- 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 +64 -20
- 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/reporter.rb
CHANGED
|
@@ -35,11 +35,13 @@ module Mutineer
|
|
|
35
35
|
# or to `out`. Diagnostics always go to `err`. `scoped` marks a diff-scoped
|
|
36
36
|
# (`--since`) run; the JSON report records it so a consumer (or a later
|
|
37
37
|
# `--baseline` load) knows the score covers only the changed-line mutants.
|
|
38
|
+
# `legacy_id_matches` (`{ignore:, baseline:}`) counts stored ids still in the
|
|
39
|
+
# old format (#126); only the JSON report records it (`summary.legacy_id_matches`).
|
|
38
40
|
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
|
|
39
|
-
baseline: nil, scoped: false)
|
|
41
|
+
baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
|
|
40
42
|
rendered =
|
|
41
43
|
if format == "json"
|
|
42
|
-
json_report(baseline, scoped: scoped)
|
|
44
|
+
json_report(baseline, scoped: scoped, legacy_id_matches: legacy_id_matches)
|
|
43
45
|
elsif format == "html"
|
|
44
46
|
html_report
|
|
45
47
|
else
|
|
@@ -131,8 +133,11 @@ module Mutineer
|
|
|
131
133
|
# @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
|
|
132
134
|
# @param scoped [Boolean] the run was diff-scoped (`--since`), so its score
|
|
133
135
|
# covers only the changed-line mutants (additive `summary.scoped` key).
|
|
136
|
+
# @param legacy_id_matches [Hash{Symbol => Integer}] `{ignore:, baseline:}`:
|
|
137
|
+
# old-format ignore entries that matched, and survivors matched in an
|
|
138
|
+
# old-format baseline only through their old id (#126).
|
|
134
139
|
# @return [String] JSON text.
|
|
135
|
-
def json_report(baseline = nil, scoped: false)
|
|
140
|
+
def json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
|
|
136
141
|
killed = @agg.killed_count
|
|
137
142
|
survived = @agg.survived_count
|
|
138
143
|
# null (not 0.0) on an empty denominator, matching the nil-vs-0.0
|
|
@@ -141,7 +146,7 @@ module Mutineer
|
|
|
141
146
|
score = @agg.mutation_score
|
|
142
147
|
|
|
143
148
|
doc = {
|
|
144
|
-
schema_version: "1.
|
|
149
|
+
schema_version: "1.4",
|
|
145
150
|
summary: {
|
|
146
151
|
total: @agg.total, killed: killed, survived: survived,
|
|
147
152
|
no_coverage: @agg.no_coverage_count,
|
|
@@ -155,7 +160,15 @@ module Mutineer
|
|
|
155
160
|
# Additive: true when the run was diff-scoped (--since). The score then
|
|
156
161
|
# covers only the changed-line mutants, so it is not comparable to a
|
|
157
162
|
# full-run score; Baseline#diff reads this to skip the score-drop gate.
|
|
158
|
-
scoped: scoped
|
|
163
|
+
scoped: scoped,
|
|
164
|
+
# Additive (1.4, #126): ids hash the project-relative file path. A
|
|
165
|
+
# baseline without this key stores old-format ids; Baseline#diff then
|
|
166
|
+
# also matches on old ids.
|
|
167
|
+
id_format: 2,
|
|
168
|
+
# Additive (1.4, #126): stored ids still in the old format. `ignore` is
|
|
169
|
+
# the number of old-format ignore entries that matched; `baseline` the
|
|
170
|
+
# survivors matched in the baseline only through their old id.
|
|
171
|
+
legacy_id_matches: legacy_id_matches
|
|
159
172
|
},
|
|
160
173
|
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
161
174
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -28,8 +28,8 @@ module Mutineer
|
|
|
28
28
|
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
29
29
|
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
30
30
|
# them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
31
|
-
# can render survivor diffs and emit the
|
|
32
|
-
# MutantId.
|
|
31
|
+
# can render survivor diffs and emit the id. `id` is the content-based
|
|
32
|
+
# MutantId (it includes the project-relative file path).
|
|
33
33
|
Result = Data.define(:status, :details, :subject, :mutation, :id) do
|
|
34
34
|
# Builds a killed result.
|
|
35
35
|
#
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "mutator_registry"
|
|
|
13
13
|
require_relative "worker_pool"
|
|
14
14
|
require_relative "progress"
|
|
15
15
|
require_relative "mutant_id"
|
|
16
|
+
require_relative "project_path"
|
|
16
17
|
require_relative "file_swap"
|
|
17
18
|
require_relative "external_backend"
|
|
18
19
|
require_relative "daemon_backend"
|
|
@@ -31,15 +32,18 @@ module Mutineer
|
|
|
31
32
|
class Runner
|
|
32
33
|
# Full orchestration: resolve operators, discover subjects, build the
|
|
33
34
|
# coverage map, run every mutation, and aggregate. Returns
|
|
34
|
-
# [AggregateResult, source_map]
|
|
35
|
-
#
|
|
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.
|
|
36
39
|
#
|
|
37
40
|
# The parent process `require`s each source file so its classes exist; forked
|
|
38
41
|
# children inherit them, so a covering test file's own require_relative of the
|
|
39
42
|
# source is a no-op and does not clobber the mutated `load` (spec §7).
|
|
40
43
|
#
|
|
41
44
|
# @param config [Mutineer::Config] run configuration.
|
|
42
|
-
# @return [Array(Mutineer::AggregateResult, Hash<String, String
|
|
45
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
|
|
46
|
+
# source map, and run extras.
|
|
43
47
|
def self.execute(config)
|
|
44
48
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
45
49
|
|
|
@@ -109,7 +113,7 @@ module Mutineer
|
|
|
109
113
|
abort_if_unclean!(coverage_map)
|
|
110
114
|
|
|
111
115
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
112
|
-
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
116
|
+
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
113
117
|
|
|
114
118
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
115
119
|
|
|
@@ -144,40 +148,75 @@ module Mutineer
|
|
|
144
148
|
sweep_orphans(dirs)
|
|
145
149
|
end
|
|
146
150
|
|
|
147
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
151
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
148
152
|
end
|
|
149
153
|
|
|
150
154
|
# Collect every (subject, mutation, id) up front so a backend can run them.
|
|
151
155
|
# A mutant the user marked known-equivalent (inline disable-line comment or
|
|
152
156
|
# .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
|
|
153
157
|
# removed from the killed+survived denominator so a strong file reaches 100%.
|
|
154
|
-
# The
|
|
155
|
-
#
|
|
156
|
-
# 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.
|
|
157
162
|
#
|
|
158
|
-
#
|
|
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`).
|
|
159
180
|
def self.collect_jobs(config, operator_classes)
|
|
160
181
|
source_map = {}
|
|
161
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] = {} }
|
|
162
188
|
ignore_set = config.ignore.to_set
|
|
163
189
|
jobs = []
|
|
164
190
|
ignored_results = []
|
|
191
|
+
legacy_ignore_matches = {}
|
|
192
|
+
id_map = {}
|
|
165
193
|
Project.discover(config.sources, only: config.only).each do |subject|
|
|
166
194
|
source = (source_map[subject.file] ||= File.read(subject.file))
|
|
167
195
|
disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
|
|
168
196
|
mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
|
|
169
|
-
|
|
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)
|
|
170
202
|
mutations.each_with_index do |mutation, i|
|
|
171
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
|
|
172
211
|
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
173
|
-
if suppressed?(mutation.operator, line, id, disabled, ignore_set)
|
|
212
|
+
if suppressed?(mutation.operator, line, [id, legacy], disabled, ignore_set)
|
|
174
213
|
ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
|
|
175
214
|
else
|
|
176
215
|
jobs << [subject, mutation, id]
|
|
177
216
|
end
|
|
178
217
|
end
|
|
179
218
|
end
|
|
180
|
-
[jobs, ignored_results, source_map]
|
|
219
|
+
[jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
|
|
181
220
|
end
|
|
182
221
|
|
|
183
222
|
# External backend orchestration. Runs each mutant's whole-file mutation on
|
|
@@ -189,7 +228,8 @@ module Mutineer
|
|
|
189
228
|
#
|
|
190
229
|
# @param config [Mutineer::Config] run configuration (test_command set).
|
|
191
230
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
192
|
-
# @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.
|
|
193
233
|
def self.execute_external(config, operator_classes)
|
|
194
234
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
195
235
|
sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
|
|
@@ -205,12 +245,12 @@ module Mutineer
|
|
|
205
245
|
# source. Heal first, then discover jobs from the clean tree.
|
|
206
246
|
FileSwap.restore_orphans(dirs)
|
|
207
247
|
|
|
208
|
-
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
248
|
+
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
209
249
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
210
250
|
|
|
211
251
|
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
212
252
|
# --test set to calibrate a timeout no mutant would use (#76).
|
|
213
|
-
next [AggregateResult.new(ignored_results), source_map] if jobs.empty?
|
|
253
|
+
next [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
|
|
214
254
|
|
|
215
255
|
# Calibrate the per-mutant timeout from the clean run (a real suite far
|
|
216
256
|
# outlasts the 10s in-process fork budget), and abort if it is not green.
|
|
@@ -234,7 +274,7 @@ module Mutineer
|
|
|
234
274
|
FileSwap.restore_orphans(dirs)
|
|
235
275
|
end
|
|
236
276
|
|
|
237
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
277
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
238
278
|
end
|
|
239
279
|
end
|
|
240
280
|
|
|
@@ -331,10 +371,14 @@ module Mutineer
|
|
|
331
371
|
end
|
|
332
372
|
|
|
333
373
|
# True when this mutant is suppressed: its line bears a disable-line marker
|
|
334
|
-
# (bare, or scoped to its operator), OR its
|
|
335
|
-
# list. Checked at job-build time so a suppressed mutant is
|
|
336
|
-
|
|
337
|
-
|
|
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) }
|
|
338
382
|
|
|
339
383
|
case (entry = disabled[line])
|
|
340
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
|
@@ -31,6 +31,8 @@ require_relative "mutineer/mutators/negation_removal"
|
|
|
31
31
|
require_relative "mutineer/mutators/chain_link"
|
|
32
32
|
require_relative "mutineer/mutators/operand_removal"
|
|
33
33
|
require_relative "mutineer/mutators/array_literal"
|
|
34
|
+
require_relative "mutineer/mutators/condition_forcing"
|
|
35
|
+
require_relative "mutineer/mutators/operator_assignment"
|
|
34
36
|
require_relative "mutineer/mutator_registry"
|
|
35
37
|
require_relative "mutineer/worker_pool"
|
|
36
38
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -77,10 +77,12 @@ files:
|
|
|
77
77
|
- lib/mutineer/mutators/chain_link.rb
|
|
78
78
|
- lib/mutineer/mutators/collection_method.rb
|
|
79
79
|
- lib/mutineer/mutators/comparison.rb
|
|
80
|
+
- lib/mutineer/mutators/condition_forcing.rb
|
|
80
81
|
- lib/mutineer/mutators/condition_negation.rb
|
|
81
82
|
- lib/mutineer/mutators/literal_mutation.rb
|
|
82
83
|
- lib/mutineer/mutators/negation_removal.rb
|
|
83
84
|
- lib/mutineer/mutators/operand_removal.rb
|
|
85
|
+
- lib/mutineer/mutators/operator_assignment.rb
|
|
84
86
|
- lib/mutineer/mutators/range_literal.rb
|
|
85
87
|
- lib/mutineer/mutators/regex_literal.rb
|
|
86
88
|
- lib/mutineer/mutators/return_nil.rb
|
|
@@ -91,6 +93,7 @@ files:
|
|
|
91
93
|
- lib/mutineer/parser.rb
|
|
92
94
|
- lib/mutineer/progress.rb
|
|
93
95
|
- lib/mutineer/project.rb
|
|
96
|
+
- lib/mutineer/project_path.rb
|
|
94
97
|
- lib/mutineer/rails_worker_db.rb
|
|
95
98
|
- lib/mutineer/reporter.rb
|
|
96
99
|
- lib/mutineer/result.rb
|