active_mutator 0.7.0 → 0.7.1
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/README.md +5 -0
- data/lib/active_mutator/example_id.rb +22 -0
- data/lib/active_mutator/runner.rb +15 -2
- data/lib/active_mutator/scheduler.rb +27 -0
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/work_item.rb +2 -0
- data/lib/active_mutator/worker.rb +45 -13
- data/lib/active_mutator.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66b813b08ab7b9c3ba792c6472ced651e2f4af2b3b530f5a07c56e938570b21c
|
|
4
|
+
data.tar.gz: 62bf64d3c76e28648300ccbf437a0334865467890f740b44205c5b417ff3229c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0736a4d6182dc5ecbcd14c2243371471f88e96033e459922f7244ce595abe1b17b65a47f3ff9c26fa1d29c8608434cd1c0198b0d5c5c58cefa54d35af9c2ea51
|
|
7
|
+
data.tar.gz: 5ea60c1a68d289dcd101857b96a4abd4d807e976f741a45ed788eeff86157b383a0ea8095fe9806ff2623aeaf70b986764fa41166656bd864aa189014cfcc03c
|
data/README.md
CHANGED
|
@@ -90,6 +90,11 @@ coverage map. The map is cached in `.active_mutator/` and refreshed
|
|
|
90
90
|
incrementally after that (see
|
|
91
91
|
[`docs/guides/how-it-works.md`](docs/guides/how-it-works.md)).
|
|
92
92
|
Then each mutant runs in its own fork against only its covering examples.
|
|
93
|
+
Top-level groups run the group that last killed a sibling mutant first,
|
|
94
|
+
then the subject's convention spec, then the cheapest remaining groups by
|
|
95
|
+
baseline time. A kill ends the fork sooner. Specs that depend on
|
|
96
|
+
another top-level group having run first can behave differently; see
|
|
97
|
+
[how it works](docs/guides/how-it-works.md).
|
|
93
98
|
|
|
94
99
|
### Reading the output
|
|
95
100
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# RSpec example ids look like "./spec/a_spec.rb[1:2:3]": the file, then the
|
|
3
|
+
# scoped path of the example. The first number is the top-level group, and a
|
|
4
|
+
# shared example adds a second bracket. Anything else parses to nil, so a bad
|
|
5
|
+
# id is never mistaken for a group.
|
|
6
|
+
module ExampleId
|
|
7
|
+
PATTERN = /\A(?<file>.+?)(?<scope>\[(?<top>\d+)(?::\d+)*\](?:\[\d+(?::\d+)*\])*)\z/
|
|
8
|
+
|
|
9
|
+
# [absolute file, top-level group number], or nil. ./spec/a_spec.rb,
|
|
10
|
+
# spec/a_spec.rb, and its absolute path all name the same file.
|
|
11
|
+
def self.group_key(id)
|
|
12
|
+
match = PATTERN.match(id) if id.is_a?(String)
|
|
13
|
+
[File.expand_path(match[:file]), match[:top]] if match
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The id with its file made absolute, for comparing spellings; or nil.
|
|
17
|
+
def self.normalize(id)
|
|
18
|
+
match = PATTERN.match(id) if id.is_a?(String)
|
|
19
|
+
"#{File.expand_path(match[:file])}#{match[:scope]}" if match
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -308,16 +308,29 @@ module ActiveMutator
|
|
|
308
308
|
end
|
|
309
309
|
|
|
310
310
|
# Single source of truth for lane/timeout/variable derivation, shared by
|
|
311
|
-
# phase-1 planning and phase-2 escalation so the two never drift.
|
|
311
|
+
# phase-1 planning and phase-2 escalation so the two never drift. Lane and
|
|
312
|
+
# budget come from the whole selection; only the order of the ids changes.
|
|
312
313
|
def build_work_item(mutation, example_ids, map)
|
|
313
314
|
lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
|
|
314
315
|
variable = map.time_for(example_ids) * @config.timeout_factor
|
|
315
316
|
boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
|
|
316
317
|
timeout = variable + @config.timeout_floor + boot_extra
|
|
317
|
-
WorkItem.new(mutation: mutation, example_ids: example_ids,
|
|
318
|
+
WorkItem.new(mutation: mutation, example_ids: prioritize(mutation, example_ids, map),
|
|
318
319
|
timeout: timeout, lane: lane, variable: variable)
|
|
319
320
|
end
|
|
320
321
|
|
|
322
|
+
# Run order for the covering set: the subject's convention spec first, then
|
|
323
|
+
# the cheapest top-level groups (summing only the selected examples), so a
|
|
324
|
+
# killing spec is found before a slow one. Ties keep sorted-selection order.
|
|
325
|
+
# Ids inside a group keep their order, and malformed ids stay their own group.
|
|
326
|
+
def prioritize(mutation, example_ids, map)
|
|
327
|
+
convention = convention_spec_rels(mutation.subject.file)
|
|
328
|
+
groups = example_ids.group_by { |id| ExampleId.group_key(id) || id }.values
|
|
329
|
+
groups.each_with_index.sort_by do |ids, position|
|
|
330
|
+
[convention.include?(BaselineDelta.spec_file_of(ids.first)) ? 0 : 1, map.time_for(ids), position]
|
|
331
|
+
end.flat_map(&:first)
|
|
332
|
+
end
|
|
333
|
+
|
|
321
334
|
# Spec files that textually match `pattern` (a constant-reference pattern
|
|
322
335
|
# for the subject's file, built via BaselineDelta.constant_reference_pattern
|
|
323
336
|
# so the escaping/word-boundary rules stay shared), minus everything phase 1
|
|
@@ -29,6 +29,7 @@ module ActiveMutator
|
|
|
29
29
|
def run(items)
|
|
30
30
|
running = {}
|
|
31
31
|
results = []
|
|
32
|
+
@killers = {} # Subject => [file, top-level group] of its latest killer
|
|
32
33
|
@abort.deferred do
|
|
33
34
|
# Browser-covered mutants each boot Chrome + an app server; running
|
|
34
35
|
# them concurrently melts CPUs and manufactures false timeouts.
|
|
@@ -92,6 +93,7 @@ module ActiveMutator
|
|
|
92
93
|
STDERR_TAIL_LINES = 20
|
|
93
94
|
|
|
94
95
|
def spawn(item, running)
|
|
96
|
+
item = prioritized(item)
|
|
95
97
|
calibrator = calibrator_for(item)
|
|
96
98
|
budget = calibrator ? calibrator.budget_for(item) : item.timeout
|
|
97
99
|
log_scale(calibrator, item.lane)
|
|
@@ -162,6 +164,29 @@ module ActiveMutator
|
|
|
162
164
|
end
|
|
163
165
|
end
|
|
164
166
|
|
|
167
|
+
# A sibling mutant of the same subject (the plan is built per mutant, so
|
|
168
|
+
# the covering set is the same or close) most likely dies to the same
|
|
169
|
+
# spec: run the group that killed the last one first. The set is unchanged.
|
|
170
|
+
def prioritized(item)
|
|
171
|
+
key = @killers[item.mutation.subject]
|
|
172
|
+
return item unless key
|
|
173
|
+
|
|
174
|
+
first, rest = item.example_ids.partition { |id| ExampleId.group_key(id) == key }
|
|
175
|
+
item.with(example_ids: first + rest)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Only a kill that names one of the examples it was given counts. Anything
|
|
179
|
+
# else (missing, malformed, foreign, or on another status) leaves the last
|
|
180
|
+
# killer alone.
|
|
181
|
+
def learn_killer(item, status, killer)
|
|
182
|
+
return unless status == :killed
|
|
183
|
+
|
|
184
|
+
selected = item.example_ids.filter_map { |id| ExampleId.normalize(id) }
|
|
185
|
+
return unless selected.include?(ExampleId.normalize(killer))
|
|
186
|
+
|
|
187
|
+
@killers[item.mutation.subject] = ExampleId.group_key(killer)
|
|
188
|
+
end
|
|
189
|
+
|
|
165
190
|
def finish(entry)
|
|
166
191
|
seconds = now - entry[:started]
|
|
167
192
|
report_line, stats_line = entry[:payload].lines.map(&:strip).reject(&:empty?)
|
|
@@ -179,6 +204,8 @@ module ActiveMutator
|
|
|
179
204
|
Result.new(mutation: entry[:item].mutation, status: :error,
|
|
180
205
|
details: "worker emitted unparseable payload", seconds: seconds)
|
|
181
206
|
else
|
|
207
|
+
# Learned only once the whole payload (report and stats) has parsed.
|
|
208
|
+
learn_killer(entry[:item], status, data["killer"]) if reported
|
|
182
209
|
Result.new(mutation: entry[:item].mutation, status: status, details: details,
|
|
183
210
|
seconds: seconds, peak_rss_kb: peak)
|
|
184
211
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# lane: :parallel (default pool) | :serial (browser-covered, one at a time)
|
|
3
3
|
# timeout: static total budget (variable + fixed), kept for --debug-plan and compat
|
|
4
|
+
# example_ids: the covering set in run priority. A worker loads the specs
|
|
5
|
+
# sorted but runs top-level groups in this order.
|
|
4
6
|
# variable: the baseline-estimate-derived part (estimate * timeout_factor) — the
|
|
5
7
|
# only part the TimeoutCalibrator scales
|
|
6
8
|
WorkItem = Data.define(:mutation, :example_ids, :timeout, :lane, :variable) do
|
|
@@ -40,9 +40,20 @@ module ActiveMutator
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def run
|
|
43
|
+
# The mutant lands in THIS process, so a mutant of the id parser or of
|
|
44
|
+
# these methods could rewrite the very code that selects the groups and
|
|
45
|
+
# reports the verdict, before the specs that kill it have run. Bind the
|
|
46
|
+
# originals first. A Worker built later (by a spec) sees the mutant.
|
|
47
|
+
@emit = method(:emit)
|
|
48
|
+
@covering_groups = method(:covering_groups)
|
|
49
|
+
@killer = method(:killer)
|
|
50
|
+
@normalize = ExampleId.method(:normalize)
|
|
51
|
+
@group_keys = @example_ids.filter_map { |id| ExampleId.group_key(id) }
|
|
43
52
|
require "rspec/core"
|
|
44
53
|
devnull = File.open(File::NULL, "w")
|
|
45
|
-
|
|
54
|
+
# Sorted so spec files load in the same order whatever the run priority:
|
|
55
|
+
# load order decides constant definitions and shared setup.
|
|
56
|
+
runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids.sort))
|
|
46
57
|
if @mutation.subject.reload?
|
|
47
58
|
require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
|
|
48
59
|
insert_mutation # BEFORE setup: groups bind described_class to the mutated object
|
|
@@ -56,15 +67,20 @@ module ActiveMutator
|
|
|
56
67
|
# is pure waste inside the fork.
|
|
57
68
|
RSpec.configuration.fail_fast = 1
|
|
58
69
|
after_fork_hygiene
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
failed_before = failed_examples.dup
|
|
71
|
+
code = runner.run_specs(@covering_groups.call)
|
|
72
|
+
if code.zero?
|
|
73
|
+
@emit.call("survived")
|
|
74
|
+
else
|
|
75
|
+
@emit.call("killed", killer: @killer.call(failed_examples - failed_before))
|
|
76
|
+
end
|
|
61
77
|
rescue ClosureReload::Skip => e
|
|
62
|
-
emit("skipped", details: e.message)
|
|
78
|
+
@emit.call("skipped", details: e.message)
|
|
63
79
|
rescue ClosureReload::MutantLoadError => e
|
|
64
80
|
# The mutation made the class unloadable; a real suite would fail on it.
|
|
65
|
-
emit("killed", details: "mutated class failed to load: #{e.message}")
|
|
81
|
+
@emit.call("killed", details: "mutated class failed to load: #{e.message}")
|
|
66
82
|
rescue StandardError, ScriptError => e
|
|
67
|
-
emit("error", details: "#{e.class}: #{e.message}")
|
|
83
|
+
@emit.call("error", details: "#{e.class}: #{e.message}")
|
|
68
84
|
end
|
|
69
85
|
|
|
70
86
|
private
|
|
@@ -88,8 +104,10 @@ module ActiveMutator
|
|
|
88
104
|
end
|
|
89
105
|
end
|
|
90
106
|
|
|
91
|
-
def emit(status, details: nil)
|
|
92
|
-
|
|
107
|
+
def emit(status, details: nil, killer: nil)
|
|
108
|
+
report = { "status" => status, "details" => details }
|
|
109
|
+
report["killer"] = killer if killer
|
|
110
|
+
@writer.puts(JSON.generate(report))
|
|
93
111
|
@writer.flush if @writer.respond_to?(:flush)
|
|
94
112
|
end
|
|
95
113
|
|
|
@@ -97,14 +115,28 @@ module ActiveMutator
|
|
|
97
115
|
# top-level groups evaluated while the PARENT preloaded the spec helper
|
|
98
116
|
# (spec/support files with RSpec.describe at load time are common). Those
|
|
99
117
|
# leak into the fork; running them would report their failures as false
|
|
100
|
-
# kills. Run only groups that belong to the covering spec files
|
|
118
|
+
# kills. Run only groups that belong to the covering spec files, in the
|
|
119
|
+
# priority order of @example_ids; groups it does not name keep host order.
|
|
101
120
|
def covering_groups
|
|
102
|
-
covering = @
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
RSpec.world.ordered_example_groups.select do |group|
|
|
121
|
+
covering = @group_keys.map(&:first).to_set
|
|
122
|
+
rank = {}
|
|
123
|
+
@group_keys.each { |key| rank[key] ||= rank.size }
|
|
124
|
+
groups = RSpec.world.ordered_example_groups.each_with_index.select do |group, _|
|
|
106
125
|
covering.include?(group.metadata[:absolute_file_path])
|
|
107
126
|
end
|
|
127
|
+
groups.sort_by do |group, host_index|
|
|
128
|
+
key = [group.metadata[:absolute_file_path], group.metadata[:scoped_id]]
|
|
129
|
+
[rank.fetch(key, rank.size), host_index]
|
|
130
|
+
end.map(&:first)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def failed_examples = RSpec.configuration.reporter.failed_examples
|
|
134
|
+
|
|
135
|
+
# The first fresh failure that is one of the examples this mutant ran
|
|
136
|
+
# against, spelled as RSpec reports it; nil when there is none.
|
|
137
|
+
def killer(failures)
|
|
138
|
+
selected = @example_ids.filter_map { |id| @normalize.call(id) }.to_set
|
|
139
|
+
failures.map(&:id).find { |id| selected.include?(@normalize.call(id)) }
|
|
108
140
|
end
|
|
109
141
|
end
|
|
110
142
|
end
|
data/lib/active_mutator.rb
CHANGED
|
@@ -39,6 +39,7 @@ require_relative "active_mutator/inserter"
|
|
|
39
39
|
require_relative "active_mutator/closure_reload"
|
|
40
40
|
require_relative "active_mutator/memory_probe"
|
|
41
41
|
require_relative "active_mutator/sampler"
|
|
42
|
+
require_relative "active_mutator/example_id"
|
|
42
43
|
require_relative "active_mutator/worker"
|
|
43
44
|
require_relative "active_mutator/result"
|
|
44
45
|
require_relative "active_mutator/work_item"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_mutator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel John
|
|
@@ -89,6 +89,7 @@ files:
|
|
|
89
89
|
- lib/active_mutator/edit.rb
|
|
90
90
|
- lib/active_mutator/engine.rb
|
|
91
91
|
- lib/active_mutator/events.rb
|
|
92
|
+
- lib/active_mutator/example_id.rb
|
|
92
93
|
- lib/active_mutator/fingerprint.rb
|
|
93
94
|
- lib/active_mutator/inserter.rb
|
|
94
95
|
- lib/active_mutator/memory_ceiling.rb
|
|
@@ -144,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
144
145
|
- !ruby/object:Gem::Version
|
|
145
146
|
version: '0'
|
|
146
147
|
requirements: []
|
|
147
|
-
rubygems_version:
|
|
148
|
+
rubygems_version: 3.6.7
|
|
148
149
|
specification_version: 4
|
|
149
150
|
summary: Mutation testing for Ruby, built on Prism
|
|
150
151
|
test_files: []
|