active_mutator 0.1.1 → 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 +103 -11
- data/lib/active_mutator/accepted_ledger.rb +22 -7
- data/lib/active_mutator/baseline_delta.rb +67 -1
- 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/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/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 +119 -15
- data/lib/active_mutator/scheduler.rb +41 -5
- 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 +3 -0
- data/lib/active_mutator.rb +8 -0
- metadata +10 -2
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Reporter
|
|
3
|
+
# GitHub Actions workflow-command projection (issue #19): one ::warning
|
|
4
|
+
# annotation per surviving mutant, inlined on the PR diff. Everything
|
|
5
|
+
# else mirrors the terminal reporter so CI logs stay readable.
|
|
6
|
+
class Github
|
|
7
|
+
def initialize(root:, out: $stdout)
|
|
8
|
+
@root = root
|
|
9
|
+
@terminal = Terminal.new(out: out)
|
|
10
|
+
@out = out
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def on_result(result) = @terminal.on_result(result)
|
|
14
|
+
|
|
15
|
+
def summary(results, invalid_count:)
|
|
16
|
+
@terminal.summary(results, invalid_count: invalid_count)
|
|
17
|
+
results.select { |r| r.status == :survived }.each { |r| annotate(r) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def annotate(result)
|
|
23
|
+
m = result.mutation
|
|
24
|
+
file = m.subject.file.delete_prefix(@root.chomp("/") + "/")
|
|
25
|
+
message = "#{m.subject.name}: #{m.description} | - #{m.original_snippet} | + #{m.edit.replacement}"
|
|
26
|
+
@out.puts "::warning file=#{file},line=#{m.line},title=Surviving mutant::#{encode(message)}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# GitHub workflow commands terminate at a raw newline; percent-encode
|
|
30
|
+
# per https://github.com/actions/toolkit runner rules.
|
|
31
|
+
def encode(message)
|
|
32
|
+
message.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -15,6 +15,7 @@ module ActiveMutator
|
|
|
15
15
|
"score" => Terminal.score(counts),
|
|
16
16
|
"counts" => counts.transform_keys(&:to_s),
|
|
17
17
|
"invalid" => invalid_count,
|
|
18
|
+
"operators" => OperatorStats.call(results),
|
|
18
19
|
"results" => results.map { |r| serialize(r) },
|
|
19
20
|
"exit_reason" => counts.fetch(:survived, 0).positive? ? "unaccepted_survivors" : "clean"
|
|
20
21
|
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
module Reporter
|
|
3
|
+
# Per-operator noise signal (issue #20): equivalent_rate =
|
|
4
|
+
# survived / (killed + survived). Every :survived result is covered by
|
|
5
|
+
# construction (uncovered mutants never reach the scheduler), so this is
|
|
6
|
+
# the covered-survivor rate. It deliberately conflates true equivalents
|
|
7
|
+
# with weak assertions — it is an aggregate signal, not a score.
|
|
8
|
+
module OperatorStats
|
|
9
|
+
def self.call(results)
|
|
10
|
+
results.group_by { |r| r.mutation.edit.operator }.to_h do |operator, group|
|
|
11
|
+
killed = group.count { |r| r.status == :killed }
|
|
12
|
+
survived = group.count { |r| r.status == :survived }
|
|
13
|
+
denominator = killed + survived
|
|
14
|
+
rate = denominator.zero? ? 0.0 : (survived.to_f / denominator).round(3)
|
|
15
|
+
[operator, { "killed" => killed, "survived" => survived, "equivalent_rate" => rate }]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
module Reporter
|
|
5
|
+
# mutation-testing-report-schema v2 (the Stryker ecosystem format).
|
|
6
|
+
# Load the written file in https://microsoft.github.io/mutation-testing-elements/
|
|
7
|
+
# for the interactive per-file mutant viewer.
|
|
8
|
+
#
|
|
9
|
+
# Schema constraints honored here: 1-based positive line/column, integer
|
|
10
|
+
# thresholds, tool-specific data only under config.active_mutator.
|
|
11
|
+
# Invalid mutants are discarded before results exist, so they appear as a
|
|
12
|
+
# count in the extras, never as CompileError mutants.
|
|
13
|
+
class StrykerJson
|
|
14
|
+
SCHEMA_URL = "https://git.io/mutation-testing-schema"
|
|
15
|
+
STATUS = { killed: "Killed", survived: "Survived", timeout: "Timeout",
|
|
16
|
+
error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored" }.freeze
|
|
17
|
+
ACCEPTED_REASON = "Accepted as equivalent in #{AcceptedLedger::FILENAME}".freeze
|
|
18
|
+
REPORT_PATH = File.join(".active_mutator", "mutation-report.json")
|
|
19
|
+
|
|
20
|
+
# Injected by Runner once the baseline map exists; nil in unit tests.
|
|
21
|
+
attr_writer :coverage_map
|
|
22
|
+
|
|
23
|
+
def initialize(root:, out: $stdout)
|
|
24
|
+
@root = root
|
|
25
|
+
@out = out
|
|
26
|
+
@coverage_map = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def on_result(result)
|
|
30
|
+
@out.print(Terminal::CHARS.fetch(result.status))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def summary(results, invalid_count:)
|
|
34
|
+
report = build_report(results, invalid_count)
|
|
35
|
+
path = File.join(@root, REPORT_PATH)
|
|
36
|
+
AtomicFile.write(path, JSON.pretty_generate(report))
|
|
37
|
+
@out.puts "", "", "Stryker report written to #{REPORT_PATH}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_report(results, invalid_count)
|
|
43
|
+
mutants_by_file = results.group_by { |r| r.mutation.subject.file }
|
|
44
|
+
report = {
|
|
45
|
+
"$schema" => SCHEMA_URL,
|
|
46
|
+
"schemaVersion" => "2",
|
|
47
|
+
"thresholds" => { "high" => 80, "low" => 60 },
|
|
48
|
+
"projectRoot" => @root,
|
|
49
|
+
"config" => { "active_mutator" => { "invalid_discarded" => invalid_count,
|
|
50
|
+
"version" => VERSION } },
|
|
51
|
+
"files" => mutants_by_file.to_h { |file, rs| [relative(file), file_entry(file, rs)] }
|
|
52
|
+
}
|
|
53
|
+
tests = referenced_examples(results)
|
|
54
|
+
report["testFiles"] = test_files(tests) unless tests.empty?
|
|
55
|
+
report
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def file_entry(file, results)
|
|
59
|
+
source = File.read(file)
|
|
60
|
+
{ "language" => "ruby", "source" => source,
|
|
61
|
+
"mutants" => results.map { |r| mutant(r, source) } }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def mutant(result, source)
|
|
65
|
+
loc = SourceLocation.locate(source, result.mutation.edit.range)
|
|
66
|
+
entry = {
|
|
67
|
+
"id" => next_id,
|
|
68
|
+
"mutatorName" => result.mutation.edit.operator,
|
|
69
|
+
"location" => { "start" => stringify(loc[:start]), "end" => stringify(loc[:end]) },
|
|
70
|
+
"status" => STATUS.fetch(result.status),
|
|
71
|
+
"replacement" => result.mutation.edit.replacement,
|
|
72
|
+
"description" => result.mutation.description
|
|
73
|
+
}
|
|
74
|
+
reason = status_reason(result)
|
|
75
|
+
entry["statusReason"] = reason if reason
|
|
76
|
+
covered = covered_by(result)
|
|
77
|
+
entry["coveredBy"] = covered if covered
|
|
78
|
+
entry
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def status_reason(result)
|
|
82
|
+
return ACCEPTED_REASON if result.status == :accepted
|
|
83
|
+
|
|
84
|
+
result.details&.to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def covered_by(result)
|
|
88
|
+
return nil unless @coverage_map
|
|
89
|
+
|
|
90
|
+
@coverage_map.examples_for(result.mutation.subject.file, result.mutation.lines)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def referenced_examples(results)
|
|
94
|
+
results.flat_map { |r| covered_by(r) || [] }.uniq.sort
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Group example ids by spec path (the id up to the trailing "[...]")
|
|
98
|
+
# so the viewer's test panel resolves coveredBy references.
|
|
99
|
+
def test_files(example_ids)
|
|
100
|
+
example_ids
|
|
101
|
+
.group_by { |id| id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "") }
|
|
102
|
+
.transform_values do |ids|
|
|
103
|
+
{ "tests" => ids.map { |id| { "id" => id, "name" => id } } }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def next_id
|
|
108
|
+
@next_id = (@next_id || -1) + 1
|
|
109
|
+
@next_id.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stringify(position) = { "line" => position[:line], "column" => position[:column] }
|
|
113
|
+
|
|
114
|
+
def relative(file) = file.delete_prefix(@root.chomp("/") + "/")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -21,6 +21,9 @@ module ActiveMutator
|
|
|
21
21
|
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
22
22
|
survivors = results.select { |r| r.status == :survived }
|
|
23
23
|
print_survivors(survivors) unless survivors.empty?
|
|
24
|
+
stats = OperatorStats.call(results)
|
|
25
|
+
noisy = stats.select { |_, s| s["survived"].positive? }
|
|
26
|
+
print_operator_stats(noisy) unless noisy.empty?
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def self.score(counts)
|
|
@@ -35,6 +38,14 @@ module ActiveMutator
|
|
|
35
38
|
|
|
36
39
|
def score(counts) = self.class.score(counts)
|
|
37
40
|
|
|
41
|
+
def print_operator_stats(stats)
|
|
42
|
+
@out.puts "", "Equivalent-rate by operator (survived / (killed + survived)):"
|
|
43
|
+
stats.sort_by { |_, s| -s["equivalent_rate"] }.each do |operator, s|
|
|
44
|
+
@out.puts format(" %-24s %5.1f%% (%d survived / %d killed)",
|
|
45
|
+
operator, s["equivalent_rate"] * 100, s["survived"], s["killed"])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
38
49
|
def print_survivors(survivors)
|
|
39
50
|
@out.puts "", "Surviving mutants:"
|
|
40
51
|
survivors.each do |result|
|
|
@@ -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
|
|
@@ -127,17 +205,43 @@ module ActiveMutator
|
|
|
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
|
-
|
|
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|
|
|
139
240
|
warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject}, #{entry.description}"
|
|
140
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})"
|
|
244
|
+
end
|
|
141
245
|
end
|
|
142
246
|
end
|
|
143
247
|
end
|
|
@@ -8,11 +8,13 @@ module ActiveMutator
|
|
|
8
8
|
OrphanedError = Class.new(Error)
|
|
9
9
|
|
|
10
10
|
def initialize(jobs:, worker: Worker.method(:run), on_result: nil,
|
|
11
|
-
orphaned: -> { Process.ppid == 1 })
|
|
11
|
+
calibrators: nil, orphaned: -> { Process.ppid == 1 })
|
|
12
12
|
@jobs = jobs
|
|
13
13
|
@worker = worker
|
|
14
14
|
@on_result = on_result
|
|
15
|
+
@calibrators = calibrators
|
|
15
16
|
@orphaned = orphaned
|
|
17
|
+
@last_logged_scale = {} # lane => last scale logged for that lane
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def run(items)
|
|
@@ -72,7 +74,12 @@ module ActiveMutator
|
|
|
72
74
|
Process.exit!(0)
|
|
73
75
|
end
|
|
74
76
|
writer.close
|
|
75
|
-
|
|
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 }
|
|
76
83
|
end
|
|
77
84
|
|
|
78
85
|
def reap(running, results)
|
|
@@ -80,7 +87,11 @@ module ActiveMutator
|
|
|
80
87
|
done, _status = Process.waitpid2(pid, Process::WNOHANG)
|
|
81
88
|
if done
|
|
82
89
|
running.delete(pid)
|
|
83
|
-
|
|
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
|
|
84
95
|
elsif now > entry[:deadline]
|
|
85
96
|
kill(pid)
|
|
86
97
|
running.delete(pid)
|
|
@@ -94,8 +105,16 @@ module ActiveMutator
|
|
|
94
105
|
payload = entry[:reader].read.to_s
|
|
95
106
|
entry[:reader].close
|
|
96
107
|
data = payload.empty? ? nil : JSON.parse(payload)
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
99
118
|
report(Result.new(mutation: entry[:item].mutation, status: status, details: details))
|
|
100
119
|
end
|
|
101
120
|
|
|
@@ -141,6 +160,23 @@ module ActiveMutator
|
|
|
141
160
|
previous.each { |sig, handler| trap(sig, handler || "DEFAULT") }
|
|
142
161
|
end
|
|
143
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
|
+
|
|
144
180
|
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
145
181
|
end
|
|
146
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
|