active_mutator 0.1.0 → 0.2.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/README.md +160 -63
- data/lib/active_mutator/accepted_ledger.rb +23 -8
- data/lib/active_mutator/atomic_file.rb +1 -1
- data/lib/active_mutator/baseline.rb +13 -4
- data/lib/active_mutator/baseline_delta.rb +67 -1
- data/lib/active_mutator/baseline_hooks.rb +2 -2
- data/lib/active_mutator/cli.rb +16 -4
- data/lib/active_mutator/config.rb +2 -1
- data/lib/active_mutator/config_file.rb +89 -0
- data/lib/active_mutator/coverage_map.rb +1 -1
- data/lib/active_mutator/defined_constants.rb +48 -0
- data/lib/active_mutator/edit.rb +8 -2
- data/lib/active_mutator/engine.rb +15 -2
- data/lib/active_mutator/fingerprint.rb +1 -1
- data/lib/active_mutator/inserter.rb +6 -3
- data/lib/active_mutator/operators/base.rb +2 -1
- data/lib/active_mutator/operators/call_swap.rb +16 -0
- data/lib/active_mutator/operators/literal.rb +14 -2
- data/lib/active_mutator/reporter/github.rb +36 -0
- data/lib/active_mutator/reporter/json.rb +1 -0
- data/lib/active_mutator/reporter/operator_stats.rb +20 -0
- data/lib/active_mutator/reporter/stryker_json.rb +117 -0
- data/lib/active_mutator/reporter/terminal.rb +11 -0
- data/lib/active_mutator/runner.rb +121 -17
- data/lib/active_mutator/scheduler.rb +64 -7
- data/lib/active_mutator/source_location.rb +21 -0
- data/lib/active_mutator/subject.rb +7 -1
- data/lib/active_mutator/subject_finder.rb +46 -7
- data/lib/active_mutator/subject_matcher.rb +23 -0
- data/lib/active_mutator/timeout_calibrator.rb +75 -0
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/work_item.rb +8 -1
- data/lib/active_mutator/worker.rb +5 -2
- data/lib/active_mutator.rb +8 -0
- metadata +13 -5
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
1
3
|
module ActiveMutator
|
|
2
4
|
class Runner
|
|
3
5
|
def initialize(config, reporter: nil)
|
|
@@ -7,24 +9,34 @@ module ActiveMutator
|
|
|
7
9
|
|
|
8
10
|
def call
|
|
9
11
|
ENV["ACTIVE_MUTATOR"] = "1"
|
|
12
|
+
load_operators
|
|
10
13
|
preload!
|
|
11
14
|
preload_spec_helper!
|
|
12
15
|
map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
|
|
16
|
+
@reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
|
|
13
17
|
subjects = discover_subjects
|
|
14
18
|
analyses = subjects.map { |s| Engine.new.analyze(s) }
|
|
15
19
|
mutations = analyses.flat_map(&:mutations)
|
|
20
|
+
mutations = mutations.first(@config.max_mutants) if @config.max_mutants
|
|
16
21
|
invalid_count = analyses.sum(&:invalid_count)
|
|
17
22
|
|
|
18
23
|
fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
|
|
19
24
|
ledger = AcceptedLedger.load(@config.root)
|
|
20
|
-
|
|
25
|
+
scanned_files = prune_scope(subjects)
|
|
26
|
+
warn_stale(ledger, fingerprints.values, scanned_files)
|
|
21
27
|
|
|
22
28
|
items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
29
|
+
return debug_plan(items, pre_results) if @config.debug_plan
|
|
30
|
+
|
|
23
31
|
pre_results.each { |r| @reporter.on_result(r) }
|
|
24
|
-
|
|
32
|
+
calibrators = if @config.adaptive_timeout
|
|
33
|
+
{ parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
|
|
34
|
+
end
|
|
35
|
+
scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
|
|
36
|
+
calibrators: calibrators)
|
|
25
37
|
results = scheduler.run(items) + pre_results
|
|
26
38
|
|
|
27
|
-
accept_survivors!(ledger, results, fingerprints) if @config.accept_survivors
|
|
39
|
+
accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
|
|
28
40
|
|
|
29
41
|
@reporter.summary(results, invalid_count: invalid_count)
|
|
30
42
|
exit_code(results)
|
|
@@ -39,27 +51,60 @@ module ActiveMutator
|
|
|
39
51
|
pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
|
|
40
52
|
next
|
|
41
53
|
end
|
|
42
|
-
example_ids = map.examples_for(mutation.subject.file, mutation
|
|
54
|
+
example_ids = map.examples_for(mutation.subject.file, coverage_lines(mutation))
|
|
43
55
|
if example_ids.empty?
|
|
44
56
|
pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
|
|
45
57
|
else
|
|
46
58
|
lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
variable = map.time_for(example_ids) * @config.timeout_factor
|
|
60
|
+
boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
|
|
61
|
+
timeout = variable + @config.timeout_floor + boot_extra
|
|
62
|
+
items << WorkItem.new(mutation: mutation, example_ids: example_ids,
|
|
63
|
+
timeout: timeout, lane: lane, variable: variable)
|
|
50
64
|
end
|
|
51
65
|
end
|
|
52
66
|
[items, pre_results]
|
|
53
67
|
end
|
|
54
68
|
|
|
55
69
|
def exit_code(results)
|
|
56
|
-
results.
|
|
70
|
+
survived = results.count { |r| r.status == :survived }
|
|
71
|
+
return 0 if survived.zero?
|
|
72
|
+
return 1 unless @config.fail_at
|
|
73
|
+
|
|
74
|
+
detected = results.count { |r| %i[killed timeout].include?(r.status) }
|
|
75
|
+
score = detected * 100.0 / (detected + survived)
|
|
76
|
+
score >= @config.fail_at ? 0 : 1
|
|
57
77
|
end
|
|
58
78
|
|
|
59
79
|
private
|
|
60
80
|
|
|
81
|
+
# Custom operators must exist in the PARENT before Engine analysis:
|
|
82
|
+
# subclassing Operators::Base self-registers, and forks inherit the
|
|
83
|
+
# loaded class. `requires` can't serve — those load inside the fork's
|
|
84
|
+
# setup, after mutations are already planned.
|
|
85
|
+
def load_operators
|
|
86
|
+
@config.operator_paths.each do |f|
|
|
87
|
+
require File.expand_path(f, @config.root)
|
|
88
|
+
rescue LoadError, SyntaxError => e
|
|
89
|
+
raise Error, "operator file not loadable: #{f}: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Line coverage attributes multi-line expressions to their statement anchor
|
|
94
|
+
# line (version-dependently), so a sub-expression mutant's own lines may
|
|
95
|
+
# carry no coverage at all. Look up the whole subject instead: a mutant must
|
|
96
|
+
# run against every example covering any line of its method.
|
|
97
|
+
def coverage_lines(mutation)
|
|
98
|
+
mutation.lines.to_a | mutation.subject.line_range.to_a
|
|
99
|
+
end
|
|
100
|
+
|
|
61
101
|
def build_reporter
|
|
62
|
-
@config.format
|
|
102
|
+
case @config.format
|
|
103
|
+
when :json then Reporter::Json.new
|
|
104
|
+
when :stryker_json then Reporter::StrykerJson.new(root: @config.root)
|
|
105
|
+
when :github then Reporter::Github.new(root: @config.root)
|
|
106
|
+
else Reporter::Terminal.new
|
|
107
|
+
end
|
|
63
108
|
end
|
|
64
109
|
|
|
65
110
|
def preload!
|
|
@@ -77,9 +122,14 @@ module ActiveMutator
|
|
|
77
122
|
def discover_subjects
|
|
78
123
|
paths = @config.paths.empty? ? default_paths : @config.paths
|
|
79
124
|
subjects = paths
|
|
80
|
-
.flat_map { |p|
|
|
125
|
+
.flat_map { |p| expand_path_arg(p) }
|
|
126
|
+
.uniq
|
|
127
|
+
.reject { |file| excluded?(file) }
|
|
81
128
|
.sort.flat_map { |file| SubjectFinder.call(file) }
|
|
82
|
-
|
|
129
|
+
if @config.subject_filter
|
|
130
|
+
matcher = SubjectMatcher.new(@config.subject_filter)
|
|
131
|
+
subjects = subjects.select { |s| matcher.match?(s.name) }
|
|
132
|
+
end
|
|
83
133
|
if @config.since
|
|
84
134
|
filter = SinceFilter.new(ref: @config.since, root: @config.root)
|
|
85
135
|
subjects = subjects.select { |s| filter.cover?(s) }
|
|
@@ -87,6 +137,34 @@ module ActiveMutator
|
|
|
87
137
|
subjects
|
|
88
138
|
end
|
|
89
139
|
|
|
140
|
+
# Positional args may be files or directories. Anything else is an error:
|
|
141
|
+
# a mistyped path that silently matched nothing produced a false green
|
|
142
|
+
# (0 subjects, exit 0) — see #23.
|
|
143
|
+
def expand_path_arg(path)
|
|
144
|
+
full = File.expand_path(path, @config.root)
|
|
145
|
+
if File.file?(full)
|
|
146
|
+
raise Error, "not a Ruby file: #{path}" unless full.end_with?(".rb")
|
|
147
|
+
|
|
148
|
+
[full]
|
|
149
|
+
elsif Dir.exist?(full)
|
|
150
|
+
Dir[File.join(full, "**", "*.rb")]
|
|
151
|
+
else
|
|
152
|
+
raise Error, "no such file or directory: #{path}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def excluded?(file)
|
|
157
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
158
|
+
relative = file.delete_prefix(@config.root.chomp("/") + "/")
|
|
159
|
+
@config.exclude.any? do |pattern|
|
|
160
|
+
# Gitignore-like ergonomics: "lib/gen", "lib/gen/" and "lib/gen/**"
|
|
161
|
+
# all exclude the whole subtree, not just direct children.
|
|
162
|
+
dir = pattern.sub(%r{(/\*\*)?/?\z}, "")
|
|
163
|
+
File.fnmatch?(pattern, relative, flags) ||
|
|
164
|
+
File.fnmatch?("#{dir}/**/*", relative, flags)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
90
168
|
def default_paths
|
|
91
169
|
%w[app lib].select { |p| Dir.exist?(File.join(@config.root, p)) }
|
|
92
170
|
end
|
|
@@ -121,22 +199,48 @@ module ActiveMutator
|
|
|
121
199
|
|
|
122
200
|
# A preloaded helper commonly starts SimpleCov. Its at_exit would fire in
|
|
123
201
|
# THIS parent process at the end of the mutation run, clobbering the
|
|
124
|
-
# project's real coverage data
|
|
202
|
+
# project's real coverage data, and minimum_coverage would exit(1) for a
|
|
125
203
|
# bogus reason. Neutralize it.
|
|
126
204
|
def disarm_simplecov
|
|
127
205
|
SimpleCov.at_exit {} if defined?(SimpleCov)
|
|
128
206
|
end
|
|
129
207
|
|
|
130
|
-
|
|
208
|
+
# Only a run with no subject-level narrowing has fully scanned a file;
|
|
209
|
+
# anything narrower must not prune (or warn about) out-of-scope entries.
|
|
210
|
+
# MAINTENANCE: any future flag that narrows the mutant set below "every
|
|
211
|
+
# subject in the scanned files" MUST be added to this nil-trigger list,
|
|
212
|
+
# or scoped accept runs will clobber out-of-scope ledger entries (#24).
|
|
213
|
+
def prune_scope(subjects)
|
|
214
|
+
return nil if @config.subject_filter || @config.since || @config.max_mutants
|
|
215
|
+
|
|
216
|
+
subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def accept_survivors!(ledger, results, fingerprints, scanned_files)
|
|
131
220
|
survivors = results.select { |r| r.status == :survived }.map { |r| fingerprints[r.mutation] }
|
|
132
221
|
return if survivors.empty?
|
|
133
222
|
|
|
134
|
-
ledger.accept!(survivors, fingerprints.values)
|
|
223
|
+
ledger.accept!(survivors, fingerprints.values, scanned_files: scanned_files)
|
|
135
224
|
end
|
|
136
225
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
226
|
+
def debug_plan(items, pre_results)
|
|
227
|
+
plan = items.map do |i|
|
|
228
|
+
{ "subject" => i.mutation.subject.name, "description" => i.mutation.description,
|
|
229
|
+
"file" => i.mutation.subject.file, "line" => i.mutation.line,
|
|
230
|
+
"lane" => i.lane.to_s, "timeout" => i.timeout.round(2),
|
|
231
|
+
"examples" => i.example_ids.size }
|
|
232
|
+
end
|
|
233
|
+
skipped = pre_results.group_by { |r| r.status.to_s }.transform_values(&:size)
|
|
234
|
+
puts JSON.pretty_generate("planned" => plan, "pre_resolved" => skipped)
|
|
235
|
+
0
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def warn_stale(ledger, all_fingerprints, scanned_files)
|
|
239
|
+
ledger.stale_entries(all_fingerprints, scanned_files: scanned_files).each do |entry|
|
|
240
|
+
warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject}, #{entry.description}"
|
|
241
|
+
end
|
|
242
|
+
ledger.missing_file_entries(@config.root).each do |entry|
|
|
243
|
+
warn "active_mutator: accepted fingerprint references missing file: #{entry.file} (#{entry.subject})"
|
|
140
244
|
end
|
|
141
245
|
end
|
|
142
246
|
end
|
|
@@ -5,10 +5,16 @@ module ActiveMutator
|
|
|
5
5
|
# Parent enforces per-item deadlines with SIGKILL (worker-side timeouts
|
|
6
6
|
# cannot interrupt all infinite loops).
|
|
7
7
|
class Scheduler
|
|
8
|
-
|
|
8
|
+
OrphanedError = Class.new(Error)
|
|
9
|
+
|
|
10
|
+
def initialize(jobs:, worker: Worker.method(:run), on_result: nil,
|
|
11
|
+
calibrators: nil, orphaned: -> { Process.ppid == 1 })
|
|
9
12
|
@jobs = jobs
|
|
10
13
|
@worker = worker
|
|
11
14
|
@on_result = on_result
|
|
15
|
+
@calibrators = calibrators
|
|
16
|
+
@orphaned = orphaned
|
|
17
|
+
@last_logged_scale = {} # lane => last scale logged for that lane
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
def run(items)
|
|
@@ -32,6 +38,7 @@ module ActiveMutator
|
|
|
32
38
|
queue = items.dup
|
|
33
39
|
results = []
|
|
34
40
|
until queue.empty? && running.empty?
|
|
41
|
+
abort_if_orphaned!(running)
|
|
35
42
|
spawn(queue.shift, running) while running.size < width && !queue.empty?
|
|
36
43
|
reap(running, results)
|
|
37
44
|
sleep 0.02 unless running.empty?
|
|
@@ -39,6 +46,22 @@ module ActiveMutator
|
|
|
39
46
|
results
|
|
40
47
|
end
|
|
41
48
|
|
|
49
|
+
# SIGKILL on the parent (or a closed terminal, or CI teardown) cannot be
|
|
50
|
+
# trapped, so a killed run would otherwise keep forking through the whole
|
|
51
|
+
# queue with nobody supervising it. Orphaned processes get reparented to
|
|
52
|
+
# init/launchd (ppid 1); when that happens, stop everything and bail.
|
|
53
|
+
def abort_if_orphaned!(running)
|
|
54
|
+
return unless @orphaned.call
|
|
55
|
+
|
|
56
|
+
running.each_key do |pid|
|
|
57
|
+
kill(pid)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
running.clear
|
|
62
|
+
raise OrphanedError, "parent process died; aborting mutation run"
|
|
63
|
+
end
|
|
64
|
+
|
|
42
65
|
def spawn(item, running)
|
|
43
66
|
reader, writer = IO.pipe
|
|
44
67
|
pid = fork do
|
|
@@ -51,7 +74,12 @@ module ActiveMutator
|
|
|
51
74
|
Process.exit!(0)
|
|
52
75
|
end
|
|
53
76
|
writer.close
|
|
54
|
-
|
|
77
|
+
calibrator = calibrator_for(item)
|
|
78
|
+
budget = calibrator ? calibrator.budget_for(item) : item.timeout
|
|
79
|
+
log_scale(calibrator, item.lane)
|
|
80
|
+
started = now
|
|
81
|
+
running[pid] = { reader: reader, item: item, started: started,
|
|
82
|
+
budget: budget, deadline: started + budget }
|
|
55
83
|
end
|
|
56
84
|
|
|
57
85
|
def reap(running, results)
|
|
@@ -59,7 +87,11 @@ module ActiveMutator
|
|
|
59
87
|
done, _status = Process.waitpid2(pid, Process::WNOHANG)
|
|
60
88
|
if done
|
|
61
89
|
running.delete(pid)
|
|
62
|
-
|
|
90
|
+
result = finish(entry)
|
|
91
|
+
if result.status == :killed
|
|
92
|
+
calibrator_for(entry[:item])&.record(now - entry[:started], entry[:budget])
|
|
93
|
+
end
|
|
94
|
+
results << result
|
|
63
95
|
elsif now > entry[:deadline]
|
|
64
96
|
kill(pid)
|
|
65
97
|
running.delete(pid)
|
|
@@ -73,8 +105,16 @@ module ActiveMutator
|
|
|
73
105
|
payload = entry[:reader].read.to_s
|
|
74
106
|
entry[:reader].close
|
|
75
107
|
data = payload.empty? ? nil : JSON.parse(payload)
|
|
76
|
-
|
|
77
|
-
|
|
108
|
+
# A self-mutation of Worker#emit can produce well-formed JSON without a
|
|
109
|
+
# "status" key (or with a non-Hash root); treat any unusable payload as
|
|
110
|
+
# a worker error instead of crashing the whole run.
|
|
111
|
+
reported = data.is_a?(Hash) && data.key?("status")
|
|
112
|
+
status = reported ? data["status"].to_sym : :error
|
|
113
|
+
details = reported ? data["details"] : "worker exited without reporting"
|
|
114
|
+
rescue JSON::ParserError
|
|
115
|
+
report(Result.new(mutation: entry[:item].mutation, status: :error,
|
|
116
|
+
details: "worker emitted unparseable payload"))
|
|
117
|
+
else
|
|
78
118
|
report(Result.new(mutation: entry[:item].mutation, status: status, details: details))
|
|
79
119
|
end
|
|
80
120
|
|
|
@@ -86,7 +126,7 @@ module ActiveMutator
|
|
|
86
126
|
def kill(pid)
|
|
87
127
|
Process.kill("KILL", -pid) # negative pid = whole process group
|
|
88
128
|
rescue Errno::ESRCH, Errno::EPERM
|
|
89
|
-
# Group not established yet (setpgid race) or already gone
|
|
129
|
+
# Group not established yet (setpgid race) or already gone: direct kill.
|
|
90
130
|
begin
|
|
91
131
|
Process.kill("KILL", pid)
|
|
92
132
|
rescue Errno::ESRCH
|
|
@@ -100,7 +140,7 @@ module ActiveMutator
|
|
|
100
140
|
end
|
|
101
141
|
end
|
|
102
142
|
|
|
103
|
-
# Returns {sig => previous_handler} so #run can restore on exit
|
|
143
|
+
# Returns {sig => previous_handler} so #run can restore on exit,
|
|
104
144
|
# otherwise our traps permanently replace the host's (e.g. RSpec's Ctrl-C).
|
|
105
145
|
def install_signal_handlers(running)
|
|
106
146
|
%w[INT TERM].to_h do |sig|
|
|
@@ -120,6 +160,23 @@ module ActiveMutator
|
|
|
120
160
|
previous.each { |sig, handler| trap(sig, handler || "DEFAULT") }
|
|
121
161
|
end
|
|
122
162
|
|
|
163
|
+
def calibrator_for(item)
|
|
164
|
+
@calibrators && @calibrators[item.lane]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Effective budgets are otherwise invisible (--debug-plan shows static
|
|
168
|
+
# ones by design). One stderr line per scale CHANGE per lane, not per
|
|
169
|
+
# spawn — the two lanes calibrate independently, so the lane is named.
|
|
170
|
+
def log_scale(calibrator, lane)
|
|
171
|
+
return unless calibrator&.warmed?
|
|
172
|
+
|
|
173
|
+
scale = calibrator.scale.round(2)
|
|
174
|
+
return if scale == @last_logged_scale[lane]
|
|
175
|
+
|
|
176
|
+
@last_logged_scale[lane] = scale
|
|
177
|
+
warn "active_mutator: adaptive timeout scale (#{lane}): #{scale}"
|
|
178
|
+
end
|
|
179
|
+
|
|
123
180
|
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
124
181
|
end
|
|
125
182
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# 1-based line/column (never 0 — the Stryker schema rejects 0) for an
|
|
3
|
+
# exclusive byte range within a source string.
|
|
4
|
+
module SourceLocation
|
|
5
|
+
def self.locate(source, byte_range)
|
|
6
|
+
{
|
|
7
|
+
start: position(source, byte_range.begin),
|
|
8
|
+
end: position(source, byte_range.end)
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.position(source, offset)
|
|
13
|
+
prefix = source.byteslice(0, offset)
|
|
14
|
+
last_newline = prefix.rindex("\n")
|
|
15
|
+
{
|
|
16
|
+
line: prefix.count("\n") + 1,
|
|
17
|
+
column: offset - (last_newline ? last_newline + 1 : 0) + 1
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# A mutable unit: one method definition.
|
|
3
3
|
# byte_range/line_range cover the whole `def ... end`.
|
|
4
|
-
|
|
4
|
+
# sclass: def lives inside `class << self` — its source slice is `def foo`,
|
|
5
|
+
# so Inserter must target the singleton class, not the constant itself.
|
|
6
|
+
Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass) do
|
|
7
|
+
def initialize(name:, file:, byte_range:, line_range:, constant_scope:, kind:, sclass: false)
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
|
|
5
11
|
def singleton? = kind == :singleton
|
|
6
12
|
end
|
|
7
13
|
end
|
|
@@ -1,36 +1,73 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
1
3
|
module ActiveMutator
|
|
2
4
|
class SubjectFinder < Prism::Visitor
|
|
5
|
+
SKIP_MARKER = /#\s*active_mutator:\s*skip\b/
|
|
6
|
+
|
|
3
7
|
def self.call(file)
|
|
4
8
|
result = Prism.parse(File.read(file))
|
|
5
9
|
return [] unless result.success?
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
skip_lines = result.comments
|
|
12
|
+
.select { |c| c.slice.match?(SKIP_MARKER) }
|
|
13
|
+
.to_set { |c| c.location.start_line }
|
|
14
|
+
finder = new(file, skip_lines: skip_lines)
|
|
8
15
|
finder.visit(result.value)
|
|
9
16
|
finder.subjects
|
|
10
17
|
end
|
|
11
18
|
|
|
12
19
|
attr_reader :subjects
|
|
13
20
|
|
|
14
|
-
def initialize(file)
|
|
21
|
+
def initialize(file, skip_lines: Set.new)
|
|
15
22
|
@file = file
|
|
23
|
+
@skip_lines = skip_lines
|
|
16
24
|
@stack = []
|
|
17
25
|
@subjects = []
|
|
26
|
+
@sclass_depth = 0
|
|
18
27
|
super()
|
|
19
28
|
end
|
|
20
29
|
|
|
30
|
+
# Classes/modules declared inside `class << self` hang their constant on
|
|
31
|
+
# the SINGLETON class, so a lexically-joined scope like "Foo::Bar" is not
|
|
32
|
+
# reachable via Object.const_get — Inserter would crash. Skipped entirely.
|
|
21
33
|
def visit_class_node(node)
|
|
34
|
+
return if @sclass_depth.positive?
|
|
35
|
+
|
|
22
36
|
with_scope(node.constant_path.slice) { super }
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def visit_module_node(node)
|
|
40
|
+
return if @sclass_depth.positive?
|
|
41
|
+
|
|
26
42
|
with_scope(node.constant_path.slice) { super }
|
|
27
43
|
end
|
|
28
44
|
|
|
29
|
-
# `class << self`
|
|
30
|
-
|
|
45
|
+
# `class << self` inside a constant scope: defs there are singleton
|
|
46
|
+
# methods of the enclosing constant. `class << obj` and a top-level
|
|
47
|
+
# `class << self` (no constant to hang the method on) stay skipped.
|
|
48
|
+
def visit_singleton_class_node(node)
|
|
49
|
+
return unless node.expression.is_a?(Prism::SelfNode) && !@stack.empty?
|
|
50
|
+
|
|
51
|
+
@sclass_depth += 1
|
|
52
|
+
begin
|
|
53
|
+
super
|
|
54
|
+
ensure
|
|
55
|
+
@sclass_depth -= 1
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Defs inside blocks (`Data.define do ... end`, `class_eval do ... end`)
|
|
60
|
+
# do not live on the enclosing constant scope, so Inserter would redefine
|
|
61
|
+
# them on the wrong constant and every mutant would falsely survive.
|
|
62
|
+
# Same v1 limit as `class << self`: not visited. Note this also hides
|
|
63
|
+
# classes/modules defined inside blocks (accepted v1 limit).
|
|
64
|
+
def visit_block_node(node); end
|
|
31
65
|
|
|
32
66
|
def visit_def_node(node)
|
|
33
|
-
|
|
67
|
+
return if @skip_lines.include?(node.location.start_line - 1)
|
|
68
|
+
|
|
69
|
+
sclass = @sclass_depth.positive?
|
|
70
|
+
singleton = sclass || node.receiver.is_a?(Prism::SelfNode)
|
|
34
71
|
scope = @stack.empty? ? nil : @stack.join("::")
|
|
35
72
|
loc = node.location
|
|
36
73
|
@subjects << Subject.new(
|
|
@@ -39,9 +76,11 @@ module ActiveMutator
|
|
|
39
76
|
byte_range: loc.start_offset...loc.end_offset,
|
|
40
77
|
line_range: loc.start_line..loc.end_line,
|
|
41
78
|
constant_scope: scope,
|
|
42
|
-
kind: singleton ? :singleton : :instance
|
|
79
|
+
kind: singleton ? :singleton : :instance,
|
|
80
|
+
sclass: sclass
|
|
43
81
|
)
|
|
44
|
-
# No `super`: nested defs
|
|
82
|
+
# No `super`: nested defs get no subject of their own -- their bodies
|
|
83
|
+
# are mutated via the OUTER def (Engine#walk descends into them).
|
|
45
84
|
end
|
|
46
85
|
|
|
47
86
|
private
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# Tiny subject-expression grammar for --subject:
|
|
3
|
+
# Foo::Bar#baz exact Foo::Bar all methods of the constant
|
|
4
|
+
# Foo::Bar* namespace Foo::Bar#* instance-only Foo::Bar.* singleton-only
|
|
5
|
+
class SubjectMatcher
|
|
6
|
+
def initialize(expression)
|
|
7
|
+
@regexp = compile(expression)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def match?(name) = @regexp.match?(name)
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def compile(expr)
|
|
15
|
+
case expr
|
|
16
|
+
when /\A(.+)([#.])\*\z/ then /\A#{Regexp.escape($1)}#{Regexp.escape($2)}[^#.]+\z/
|
|
17
|
+
when /\A(.+)\*\z/ then /\A#{Regexp.escape($1)}/
|
|
18
|
+
when /[#.]/ then /\A#{Regexp.escape(expr)}\z/
|
|
19
|
+
else /\A#{Regexp.escape(expr)}[#.][^#.]+\z/
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# Adaptive timeout budgets (#9). Static budgets derive from baseline times
|
|
3
|
+
# measured warm and unloaded; under parallel load they misclassify
|
|
4
|
+
# slow-but-honest kills as timeouts. The Scheduler feeds this with the
|
|
5
|
+
# observed wall time of every KILLED fork (errors exit artificially fast,
|
|
6
|
+
# survivors run their whole covering set — both would bias the median;
|
|
7
|
+
# timed-out forks have no known wall time at all). One instance per lane:
|
|
8
|
+
# parallel and serial run under different load regimes.
|
|
9
|
+
#
|
|
10
|
+
# Utilization is elapsed / the EFFECTIVE budget the fork ran under (the
|
|
11
|
+
# value budget_for returned at its spawn), not the static item.timeout —
|
|
12
|
+
# otherwise a pinned-high scale could never observe recovery (ratchet).
|
|
13
|
+
# Once WARMUP observations exist, remaining budgets' variable part is
|
|
14
|
+
# scaled by the clamped median utilization. The fixed part (timeout_floor
|
|
15
|
+
# + browser boot) is never scaled: fork boot cost does not shrink because
|
|
16
|
+
# examples run fast.
|
|
17
|
+
#
|
|
18
|
+
# The scale is grow-only (MIN_SCALE = 1.0): budgets may only extend beyond
|
|
19
|
+
# the static value, never fall below it. Downscaling has no recovery signal
|
|
20
|
+
# because timeouts are CENSORED samples — the scheduler skips them (they have
|
|
21
|
+
# no known wall time), so only killed forks feed the window. A low median
|
|
22
|
+
# utilization would shrink the budget, causing a legitimately slow kill to be
|
|
23
|
+
# reaped as a timeout; that censored kill never enters the window, so nothing
|
|
24
|
+
# ever pushes utilization back up. The shrink self-sustains — an asymmetric
|
|
25
|
+
# ratchet. Growing is safe (a too-large budget still records a real kill and
|
|
26
|
+
# relaxes); shrinking is not, so we forbid it.
|
|
27
|
+
class TimeoutCalibrator
|
|
28
|
+
WARMUP = 5
|
|
29
|
+
# A real sliding window, not full-run history: a 500-mutant run's early
|
|
30
|
+
# load regime must age out completely instead of anchoring the median
|
|
31
|
+
# forever. 30 samples is enough for a stable median and small enough to
|
|
32
|
+
# track load changes within a few dozen finishes.
|
|
33
|
+
WINDOW = 30
|
|
34
|
+
TARGET_UTILIZATION = 0.25
|
|
35
|
+
MIN_SCALE = 1.0
|
|
36
|
+
MAX_SCALE = 4.0
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@utilizations = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def record(elapsed_seconds, budget)
|
|
43
|
+
return unless budget.positive?
|
|
44
|
+
|
|
45
|
+
@utilizations << elapsed_seconds / budget
|
|
46
|
+
@utilizations.shift while @utilizations.size > WINDOW
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def warmed? = @utilizations.size >= WARMUP
|
|
50
|
+
|
|
51
|
+
def budget_for(item)
|
|
52
|
+
return item.timeout unless warmed?
|
|
53
|
+
|
|
54
|
+
fixed = item.timeout - item.variable
|
|
55
|
+
item.variable * scale + fixed
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def scale
|
|
59
|
+
# An empty window has no median; a caller invoking scale without warmed?
|
|
60
|
+
# (median of [] is nil) would otherwise raise. Neutral scale = no change.
|
|
61
|
+
return 1.0 if @utilizations.empty?
|
|
62
|
+
|
|
63
|
+
s = median(@utilizations) / TARGET_UTILIZATION
|
|
64
|
+
s.clamp(MIN_SCALE, MAX_SCALE)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def median(values)
|
|
70
|
+
sorted = values.sort
|
|
71
|
+
mid = sorted.size / 2
|
|
72
|
+
sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -1,4 +1,11 @@
|
|
|
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
|
+
# variable: the baseline-estimate-derived part (estimate * timeout_factor) — the
|
|
5
|
+
# only part the TimeoutCalibrator scales
|
|
6
|
+
WorkItem = Data.define(:mutation, :example_ids, :timeout, :lane, :variable) do
|
|
7
|
+
def initialize(mutation:, example_ids:, timeout:, lane:, variable: 0.0)
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
4
11
|
end
|
|
@@ -3,7 +3,7 @@ require "set"
|
|
|
3
3
|
|
|
4
4
|
module ActiveMutator
|
|
5
5
|
# Runs INSIDE a fork. Order is critical: RSpec's setup phase loads the spec
|
|
6
|
-
# files, whose spec_helper/rails_helper loads the application
|
|
6
|
+
# files, whose spec_helper/rails_helper loads the application. Only THEN
|
|
7
7
|
# can the mutation be inserted over the loaded original. Insert-first would
|
|
8
8
|
# NameError on any project not preloaded in the parent (all non-Rails
|
|
9
9
|
# projects), and loading app code after insertion would silently restore
|
|
@@ -24,6 +24,9 @@ module ActiveMutator
|
|
|
24
24
|
devnull = File.open(File::NULL, "w")
|
|
25
25
|
runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
|
|
26
26
|
runner.setup(devnull, devnull) # loads spec files -> loads the app
|
|
27
|
+
# One failure kills the mutant; running the rest of the covering set
|
|
28
|
+
# is pure waste inside the fork.
|
|
29
|
+
RSpec.configuration.fail_fast = 1
|
|
27
30
|
Inserter.new.insert(@mutation) # now the target constant exists
|
|
28
31
|
after_fork_hygiene
|
|
29
32
|
code = runner.run_specs(covering_groups)
|
|
@@ -47,7 +50,7 @@ module ActiveMutator
|
|
|
47
50
|
@writer.flush if @writer.respond_to?(:flush)
|
|
48
51
|
end
|
|
49
52
|
|
|
50
|
-
# RSpec.world holds every group registered in the process
|
|
53
|
+
# RSpec.world holds every group registered in the process, including any
|
|
51
54
|
# top-level groups evaluated while the PARENT preloaded the spec helper
|
|
52
55
|
# (spec/support files with RSpec.describe at load time are common). Those
|
|
53
56
|
# leak into the fork; running them would report their failures as false
|
data/lib/active_mutator.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "active_mutator/edit"
|
|
|
11
11
|
require_relative "active_mutator/splicer"
|
|
12
12
|
require_relative "active_mutator/subject"
|
|
13
13
|
require_relative "active_mutator/subject_finder"
|
|
14
|
+
require_relative "active_mutator/subject_matcher"
|
|
14
15
|
require_relative "active_mutator/operators/base"
|
|
15
16
|
require_relative "active_mutator/operators/conditional_boundary"
|
|
16
17
|
require_relative "active_mutator/operators/condition_forcing"
|
|
@@ -27,16 +28,23 @@ require_relative "active_mutator/atomic_file"
|
|
|
27
28
|
require_relative "active_mutator/coverage_map"
|
|
28
29
|
require_relative "active_mutator/baseline"
|
|
29
30
|
require_relative "active_mutator/baseline_delta"
|
|
31
|
+
require_relative "active_mutator/defined_constants"
|
|
30
32
|
require_relative "active_mutator/inserter"
|
|
31
33
|
require_relative "active_mutator/worker"
|
|
32
34
|
require_relative "active_mutator/result"
|
|
33
35
|
require_relative "active_mutator/work_item"
|
|
36
|
+
require_relative "active_mutator/timeout_calibrator"
|
|
34
37
|
require_relative "active_mutator/scheduler"
|
|
38
|
+
require_relative "active_mutator/reporter/operator_stats"
|
|
35
39
|
require_relative "active_mutator/reporter/terminal"
|
|
40
|
+
require_relative "active_mutator/reporter/github"
|
|
36
41
|
require_relative "active_mutator/reporter/json"
|
|
42
|
+
require_relative "active_mutator/source_location"
|
|
37
43
|
require_relative "active_mutator/since_filter"
|
|
38
44
|
require_relative "active_mutator/fingerprint"
|
|
39
45
|
require_relative "active_mutator/accepted_ledger"
|
|
46
|
+
require_relative "active_mutator/reporter/stryker_json"
|
|
40
47
|
require_relative "active_mutator/config"
|
|
41
48
|
require_relative "active_mutator/runner"
|
|
49
|
+
require_relative "active_mutator/config_file"
|
|
42
50
|
require_relative "active_mutator/cli"
|