active_mutator 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +156 -13
- data/lib/active_mutator/accepted_ledger.rb +22 -7
- data/lib/active_mutator/baseline_delta.rb +78 -1
- data/lib/active_mutator/class_shape.rb +47 -0
- data/lib/active_mutator/cli.rb +17 -4
- data/lib/active_mutator/closure_reload.rb +202 -0
- data/lib/active_mutator/config.rb +3 -1
- data/lib/active_mutator/config_file.rb +92 -0
- data/lib/active_mutator/defined_constants.rb +48 -0
- data/lib/active_mutator/edit.rb +8 -2
- data/lib/active_mutator/engine.rb +121 -7
- 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 +128 -0
- data/lib/active_mutator/reporter/terminal.rb +24 -1
- data/lib/active_mutator/result.rb +1 -1
- data/lib/active_mutator/runner.rb +239 -19
- data/lib/active_mutator/scheduler.rb +41 -5
- data/lib/active_mutator/source_location.rb +21 -0
- data/lib/active_mutator/subject.rb +13 -3
- data/lib/active_mutator/subject_finder.rb +89 -9
- 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 +53 -8
- data/lib/active_mutator.rb +10 -0
- metadata +20 -5
|
@@ -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,128 @@
|
|
|
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",
|
|
17
|
+
skipped: "Ignored" }.freeze
|
|
18
|
+
ACCEPTED_REASON = "Accepted as equivalent in #{AcceptedLedger::FILENAME}".freeze
|
|
19
|
+
REPORT_PATH = File.join(".active_mutator", "mutation-report.json")
|
|
20
|
+
|
|
21
|
+
# Injected by Runner once the baseline map exists; nil in unit tests.
|
|
22
|
+
attr_writer :coverage_map
|
|
23
|
+
|
|
24
|
+
def initialize(root:, out: $stdout)
|
|
25
|
+
@root = root
|
|
26
|
+
@out = out
|
|
27
|
+
@coverage_map = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_result(result)
|
|
31
|
+
@out.print(Terminal::CHARS.fetch(result.status))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def summary(results, invalid_count:)
|
|
35
|
+
report = build_report(results, invalid_count)
|
|
36
|
+
path = File.join(@root, REPORT_PATH)
|
|
37
|
+
AtomicFile.write(path, JSON.pretty_generate(report))
|
|
38
|
+
@out.puts "", "", "Stryker report written to #{REPORT_PATH}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_report(results, invalid_count)
|
|
44
|
+
mutants_by_file = results.group_by { |r| r.mutation.subject.file }
|
|
45
|
+
report = {
|
|
46
|
+
"$schema" => SCHEMA_URL,
|
|
47
|
+
"schemaVersion" => "2",
|
|
48
|
+
"thresholds" => { "high" => 80, "low" => 60 },
|
|
49
|
+
"projectRoot" => @root,
|
|
50
|
+
"config" => { "active_mutator" => { "invalid_discarded" => invalid_count,
|
|
51
|
+
"version" => VERSION } },
|
|
52
|
+
"files" => mutants_by_file.to_h { |file, rs| [relative(file), file_entry(file, rs)] }
|
|
53
|
+
}
|
|
54
|
+
tests = referenced_examples(results)
|
|
55
|
+
report["testFiles"] = test_files(tests) unless tests.empty?
|
|
56
|
+
report
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def file_entry(file, results)
|
|
60
|
+
source = File.read(file)
|
|
61
|
+
{ "language" => "ruby", "source" => source,
|
|
62
|
+
"mutants" => results.map { |r| mutant(r, source) } }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def mutant(result, source)
|
|
66
|
+
loc = SourceLocation.locate(source, result.mutation.edit.range)
|
|
67
|
+
entry = {
|
|
68
|
+
"id" => next_id,
|
|
69
|
+
"mutatorName" => result.mutation.edit.operator,
|
|
70
|
+
"location" => { "start" => stringify(loc[:start]), "end" => stringify(loc[:end]) },
|
|
71
|
+
"status" => STATUS.fetch(result.status),
|
|
72
|
+
"replacement" => result.mutation.edit.replacement,
|
|
73
|
+
"description" => result.mutation.description
|
|
74
|
+
}
|
|
75
|
+
reason = status_reason(result)
|
|
76
|
+
entry["statusReason"] = reason if reason
|
|
77
|
+
covered = covered_by(result)
|
|
78
|
+
entry["coveredBy"] = covered if covered
|
|
79
|
+
entry
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def status_reason(result)
|
|
83
|
+
return ACCEPTED_REASON if result.status == :accepted
|
|
84
|
+
|
|
85
|
+
result.details&.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def covered_by(result)
|
|
89
|
+
return nil unless @coverage_map
|
|
90
|
+
|
|
91
|
+
subject = result.mutation.subject
|
|
92
|
+
# Class-body lines execute at load time, so per-line coverage never
|
|
93
|
+
# attributes examples to them (see Runner#examples_for_mutation). Mirror
|
|
94
|
+
# the scheduling substitution — every example that loaded the file — so
|
|
95
|
+
# the viewer shows real test linkage instead of an empty coveredBy.
|
|
96
|
+
if subject.class_body?
|
|
97
|
+
examples = @coverage_map.examples_covering_file(subject.file)
|
|
98
|
+
return examples.empty? ? nil : examples.sort
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@coverage_map.examples_for(subject.file, result.mutation.lines)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def referenced_examples(results)
|
|
105
|
+
results.flat_map { |r| covered_by(r) || [] }.uniq.sort
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Group example ids by spec path (the id up to the trailing "[...]")
|
|
109
|
+
# so the viewer's test panel resolves coveredBy references.
|
|
110
|
+
def test_files(example_ids)
|
|
111
|
+
example_ids
|
|
112
|
+
.group_by { |id| id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "") }
|
|
113
|
+
.transform_values do |ids|
|
|
114
|
+
{ "tests" => ids.map { |id| { "id" => id, "name" => id } } }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def next_id
|
|
119
|
+
@next_id = (@next_id || -1) + 1
|
|
120
|
+
@next_id.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def stringify(position) = { "line" => position[:line], "column" => position[:column] }
|
|
124
|
+
|
|
125
|
+
def relative(file) = file.delete_prefix(@root.chomp("/") + "/")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
module Reporter
|
|
3
3
|
class Terminal
|
|
4
|
-
CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A"
|
|
4
|
+
CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A",
|
|
5
|
+
skipped: "-" }.freeze
|
|
5
6
|
|
|
6
7
|
def initialize(out: $stdout)
|
|
7
8
|
@out = out
|
|
@@ -21,6 +22,11 @@ module ActiveMutator
|
|
|
21
22
|
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
22
23
|
survivors = results.select { |r| r.status == :survived }
|
|
23
24
|
print_survivors(survivors) unless survivors.empty?
|
|
25
|
+
skipped = results.select { |r| r.status == :skipped }
|
|
26
|
+
print_skipped(skipped) unless skipped.empty?
|
|
27
|
+
stats = OperatorStats.call(results)
|
|
28
|
+
noisy = stats.select { |_, s| s["survived"].positive? }
|
|
29
|
+
print_operator_stats(noisy) unless noisy.empty?
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def self.score(counts)
|
|
@@ -35,6 +41,14 @@ module ActiveMutator
|
|
|
35
41
|
|
|
36
42
|
def score(counts) = self.class.score(counts)
|
|
37
43
|
|
|
44
|
+
def print_operator_stats(stats)
|
|
45
|
+
@out.puts "", "Equivalent-rate by operator (survived / (killed + survived)):"
|
|
46
|
+
stats.sort_by { |_, s| -s["equivalent_rate"] }.each do |operator, s|
|
|
47
|
+
@out.puts format(" %-24s %5.1f%% (%d survived / %d killed)",
|
|
48
|
+
operator, s["equivalent_rate"] * 100, s["survived"], s["killed"])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
38
52
|
def print_survivors(survivors)
|
|
39
53
|
@out.puts "", "Surviving mutants:"
|
|
40
54
|
survivors.each do |result|
|
|
@@ -43,6 +57,15 @@ module ActiveMutator
|
|
|
43
57
|
@out.puts " #{m.description}"
|
|
44
58
|
@out.puts " - #{m.original_snippet}"
|
|
45
59
|
@out.puts " + #{m.edit.replacement}"
|
|
60
|
+
@out.puts " (#{result.details})" if result.details
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def print_skipped(skipped)
|
|
65
|
+
@out.puts "", "Skipped mutants (not counted in the score):"
|
|
66
|
+
skipped.each do |result|
|
|
67
|
+
m = result.mutation
|
|
68
|
+
@out.puts " #{m.subject.name} (#{m.subject.file}:#{m.line}): #{result.details}"
|
|
46
69
|
end
|
|
47
70
|
end
|
|
48
71
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
|
-
# status: :killed | :survived | :timeout | :error | :uncovered | :accepted
|
|
2
|
+
# status: :killed | :survived | :timeout | :error | :uncovered | :accepted | :skipped
|
|
3
3
|
Result = Data.define(:mutation, :status, :details) do
|
|
4
4
|
def detected? = %i[killed timeout].include?(status)
|
|
5
5
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
1
3
|
module ActiveMutator
|
|
2
4
|
class Runner
|
|
3
5
|
def initialize(config, reporter: nil)
|
|
@@ -7,30 +9,45 @@ module ActiveMutator
|
|
|
7
9
|
|
|
8
10
|
def call
|
|
9
11
|
ENV["ACTIVE_MUTATOR"] = "1"
|
|
12
|
+
load_operators
|
|
13
|
+
ClosureReload.cap = @config.class_level_closure_cap
|
|
10
14
|
preload!
|
|
11
15
|
preload_spec_helper!
|
|
12
16
|
map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
|
|
17
|
+
@reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
|
|
13
18
|
subjects = discover_subjects
|
|
14
19
|
analyses = subjects.map { |s| Engine.new.analyze(s) }
|
|
15
20
|
mutations = analyses.flat_map(&:mutations)
|
|
21
|
+
mutations = mutations.first(@config.max_mutants) if @config.max_mutants
|
|
16
22
|
invalid_count = analyses.sum(&:invalid_count)
|
|
17
23
|
|
|
18
24
|
fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
|
|
19
25
|
ledger = AcceptedLedger.load(@config.root)
|
|
20
|
-
|
|
26
|
+
scanned_files = prune_scope(subjects)
|
|
27
|
+
warn_stale(ledger, fingerprints.values, scanned_files)
|
|
28
|
+
|
|
29
|
+
items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
30
|
+
return debug_plan(items, pre_results) if @config.debug_plan
|
|
21
31
|
|
|
22
|
-
items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
23
32
|
pre_results.each { |r| @reporter.on_result(r) }
|
|
24
|
-
|
|
33
|
+
calibrators = if @config.adaptive_timeout
|
|
34
|
+
{ parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
|
|
35
|
+
end
|
|
36
|
+
scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
|
|
37
|
+
calibrators: calibrators)
|
|
25
38
|
results = scheduler.run(items) + pre_results
|
|
39
|
+
# Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
|
|
40
|
+
results = escalate_class_body_survivors(results, nil, map, phase1_ids: phase1_ids)
|
|
26
41
|
|
|
27
|
-
accept_survivors!(ledger, results, fingerprints) if @config.accept_survivors
|
|
42
|
+
accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
|
|
28
43
|
|
|
29
44
|
@reporter.summary(results, invalid_count: invalid_count)
|
|
30
45
|
exit_code(results)
|
|
31
46
|
end
|
|
32
47
|
|
|
33
|
-
# Returns [work_items, pre_results].
|
|
48
|
+
# Returns [work_items, pre_results, phase1_ids]. phase1_ids maps each
|
|
49
|
+
# planned mutation to the example ids it was scheduled against, so phase 2
|
|
50
|
+
# escalation can subtract what was already run. Public for unit testing.
|
|
34
51
|
def plan_work(mutations, map, ledger: nil, fingerprints: {})
|
|
35
52
|
items = []
|
|
36
53
|
pre_results = []
|
|
@@ -39,27 +56,167 @@ module ActiveMutator
|
|
|
39
56
|
pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
|
|
40
57
|
next
|
|
41
58
|
end
|
|
42
|
-
example_ids =
|
|
59
|
+
example_ids = examples_for_mutation(mutation, map)
|
|
43
60
|
if example_ids.empty?
|
|
44
61
|
pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
|
|
45
62
|
else
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
items << build_work_item(mutation, example_ids, map)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
phase1_ids = items.to_h { |i| [i.mutation, i.example_ids] }
|
|
67
|
+
[items, pre_results, phase1_ids]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Phase 2 of the class-body kill pipeline (public for unit testing).
|
|
71
|
+
# A class-body survivor is only DECLARED after every spec file that
|
|
72
|
+
# references the constant has had its shot: re-enqueue against the
|
|
73
|
+
# referencing files phase 1 didn't run, and take the escalated verdict.
|
|
74
|
+
#
|
|
75
|
+
# `scheduler` is injectable for unit tests; in the normal run it is nil and
|
|
76
|
+
# a dedicated escalation scheduler is built lazily (only when there is
|
|
77
|
+
# phase-2 work) with NO on_result — escalation is a refinement pass, and
|
|
78
|
+
# reporting through the live callback would print a second status char for a
|
|
79
|
+
# mutant already streamed in phase 1. The final summary reflects the
|
|
80
|
+
# escalated verdicts regardless.
|
|
81
|
+
def escalate_class_body_survivors(results, scheduler, map, phase1_ids:)
|
|
82
|
+
candidates = results.select { |r| r.status == :survived && r.mutation.subject.class_body? }
|
|
83
|
+
# Perf gate: skip reading the whole spec suite into memory in the common
|
|
84
|
+
# case of no class-body survivors. (Deleting this line is a behavioral
|
|
85
|
+
# no-op — the later `items.empty?` return still guards correctness — so
|
|
86
|
+
# its mutant is a known equivalent.)
|
|
87
|
+
return results if candidates.empty?
|
|
88
|
+
|
|
89
|
+
spec_contents = Dir[File.join(@config.root, "spec/**/*_spec.rb")].to_h { |f| [f, File.read(f)] }
|
|
90
|
+
patterns = {} # subject file => constant-reference pattern (parsed once per file)
|
|
91
|
+
items = {}
|
|
92
|
+
candidates.each do |r|
|
|
93
|
+
file = r.mutation.subject.file
|
|
94
|
+
pattern = patterns.fetch(file) do
|
|
95
|
+
patterns[file] = BaselineDelta.constant_reference_pattern(File.read(file))
|
|
96
|
+
end
|
|
97
|
+
next unless pattern
|
|
98
|
+
|
|
99
|
+
ids = escalation_examples(map, spec_contents, phase1_ids.fetch(r.mutation, []), pattern)
|
|
100
|
+
next if ids.empty?
|
|
101
|
+
|
|
102
|
+
items[r.mutation] = build_work_item(r.mutation, ids, map)
|
|
103
|
+
end
|
|
104
|
+
return results if items.empty?
|
|
105
|
+
|
|
106
|
+
scheduler ||= Scheduler.new(jobs: @config.jobs)
|
|
107
|
+
escalated = scheduler.run(items.values).to_h { |res| [res.mutation, res] }
|
|
108
|
+
results.map do |r|
|
|
109
|
+
# A replacement only ever exists for a survived candidate (items is
|
|
110
|
+
# built solely from those), so no redundant status re-check is needed.
|
|
111
|
+
replacement = escalated[r.mutation]
|
|
112
|
+
next r unless replacement
|
|
113
|
+
|
|
114
|
+
case replacement.status
|
|
115
|
+
when :killed
|
|
116
|
+
replacement
|
|
117
|
+
when :survived
|
|
118
|
+
extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
|
|
119
|
+
replacement.with(details: "escalated (+#{extra} spec files)")
|
|
120
|
+
else
|
|
121
|
+
# A timeout/error/skip in phase 2 did NOT prove a kill — the mutant
|
|
122
|
+
# already survived phase 1, so keep that verdict rather than letting
|
|
123
|
+
# an inconclusive escalation inflate the score (a :timeout counts as
|
|
124
|
+
# detected in exit_code/score).
|
|
125
|
+
r
|
|
50
126
|
end
|
|
51
127
|
end
|
|
52
|
-
[items, pre_results]
|
|
53
128
|
end
|
|
54
129
|
|
|
55
130
|
def exit_code(results)
|
|
56
|
-
results.
|
|
131
|
+
survived = results.count { |r| r.status == :survived }
|
|
132
|
+
return 0 if survived.zero?
|
|
133
|
+
return 1 unless @config.fail_at
|
|
134
|
+
|
|
135
|
+
detected = results.count { |r| %i[killed timeout].include?(r.status) }
|
|
136
|
+
score = detected * 100.0 / (detected + survived)
|
|
137
|
+
score >= @config.fail_at ? 0 : 1
|
|
57
138
|
end
|
|
58
139
|
|
|
59
140
|
private
|
|
60
141
|
|
|
142
|
+
# Single source of truth for lane/timeout/variable derivation, shared by
|
|
143
|
+
# phase-1 planning and phase-2 escalation so the two never drift.
|
|
144
|
+
def build_work_item(mutation, example_ids, map)
|
|
145
|
+
lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
|
|
146
|
+
variable = map.time_for(example_ids) * @config.timeout_factor
|
|
147
|
+
boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
|
|
148
|
+
timeout = variable + @config.timeout_floor + boot_extra
|
|
149
|
+
WorkItem.new(mutation: mutation, example_ids: example_ids,
|
|
150
|
+
timeout: timeout, lane: lane, variable: variable)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Spec files that textually match `pattern` (a constant-reference pattern
|
|
154
|
+
# for the subject's file, built via BaselineDelta.constant_reference_pattern
|
|
155
|
+
# so the escaping/word-boundary rules stay shared), minus everything phase 1
|
|
156
|
+
# already ran; returned as example ids.
|
|
157
|
+
#
|
|
158
|
+
# Two deliberate choices: (a) matching is TEXTUAL, so a constant named in a
|
|
159
|
+
# comment or string still counts — intentional, since the worst case is a
|
|
160
|
+
# wasted run and the verdict stays correct; (b) unlike
|
|
161
|
+
# BaselineDelta.newly_covering_candidates there is intentionally NO fan-out
|
|
162
|
+
# ceiling here — a class-body survivor gets every referencing spec its shot
|
|
163
|
+
# before being declared.
|
|
164
|
+
def escalation_examples(map, spec_contents, phase1_example_ids, pattern)
|
|
165
|
+
phase1_files = phase1_example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq
|
|
166
|
+
spec_contents.filter_map do |abs, content|
|
|
167
|
+
rel = abs.delete_prefix(@config.root.chomp("/") + "/")
|
|
168
|
+
next if phase1_files.include?(rel)
|
|
169
|
+
next unless content.match?(pattern)
|
|
170
|
+
|
|
171
|
+
map.examples_for_spec_file(rel)
|
|
172
|
+
end.flatten.uniq.sort
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Custom operators must exist in the PARENT before Engine analysis:
|
|
176
|
+
# subclassing Operators::Base self-registers, and forks inherit the
|
|
177
|
+
# loaded class. `requires` can't serve — those load inside the fork's
|
|
178
|
+
# setup, after mutations are already planned.
|
|
179
|
+
def load_operators
|
|
180
|
+
@config.operators.each do |f|
|
|
181
|
+
require File.expand_path(f, @config.root)
|
|
182
|
+
rescue LoadError, SyntaxError => e
|
|
183
|
+
raise Error, "operator file not loadable: #{f}: #{e.message}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Line coverage attributes multi-line expressions to their statement anchor
|
|
188
|
+
# line (version-dependently), so a sub-expression mutant's own lines may
|
|
189
|
+
# carry no coverage at all. Look up the whole subject instead: a mutant must
|
|
190
|
+
# run against every example covering any line of its method.
|
|
191
|
+
def coverage_lines(mutation)
|
|
192
|
+
mutation.lines.to_a | mutation.subject.line_range.to_a
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Class-body lines execute at load time, so line coverage never
|
|
196
|
+
# attributes examples to them. Substitute: every example that covers ANY
|
|
197
|
+
# line of the file (it must have loaded the class), plus the convention
|
|
198
|
+
# spec file's examples. Phase 2 (escalation) widens further before a
|
|
199
|
+
# survivor is declared.
|
|
200
|
+
def examples_for_mutation(mutation, map)
|
|
201
|
+
return map.examples_for(mutation.subject.file, coverage_lines(mutation)) unless mutation.subject.class_body?
|
|
202
|
+
|
|
203
|
+
(map.examples_covering_file(mutation.subject.file) |
|
|
204
|
+
map.examples_for_spec_file(convention_spec_rel(mutation.subject.file))).sort
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def convention_spec_rel(file)
|
|
208
|
+
rel = file.delete_prefix(@config.root.chomp("/") + "/").delete_suffix(".rb")
|
|
209
|
+
rest = rel.sub(%r{\A[^/]+/}, "")
|
|
210
|
+
"spec/#{rest}_spec.rb"
|
|
211
|
+
end
|
|
212
|
+
|
|
61
213
|
def build_reporter
|
|
62
|
-
@config.format
|
|
214
|
+
case @config.format
|
|
215
|
+
when :json then Reporter::Json.new
|
|
216
|
+
when :stryker_json then Reporter::StrykerJson.new(root: @config.root)
|
|
217
|
+
when :github then Reporter::Github.new(root: @config.root)
|
|
218
|
+
else Reporter::Terminal.new
|
|
219
|
+
end
|
|
63
220
|
end
|
|
64
221
|
|
|
65
222
|
def preload!
|
|
@@ -77,9 +234,15 @@ module ActiveMutator
|
|
|
77
234
|
def discover_subjects
|
|
78
235
|
paths = @config.paths.empty? ? default_paths : @config.paths
|
|
79
236
|
subjects = paths
|
|
80
|
-
.flat_map { |p|
|
|
237
|
+
.flat_map { |p| expand_path_arg(p) }
|
|
238
|
+
.uniq
|
|
239
|
+
.reject { |file| excluded?(file) }
|
|
81
240
|
.sort.flat_map { |file| SubjectFinder.call(file) }
|
|
82
|
-
subjects = subjects.
|
|
241
|
+
subjects = subjects.reject(&:class_body?) unless @config.class_level
|
|
242
|
+
if @config.subject_filter
|
|
243
|
+
matcher = SubjectMatcher.new(@config.subject_filter)
|
|
244
|
+
subjects = subjects.select { |s| matcher.match?(s.name) }
|
|
245
|
+
end
|
|
83
246
|
if @config.since
|
|
84
247
|
filter = SinceFilter.new(ref: @config.since, root: @config.root)
|
|
85
248
|
subjects = subjects.select { |s| filter.cover?(s) }
|
|
@@ -87,6 +250,34 @@ module ActiveMutator
|
|
|
87
250
|
subjects
|
|
88
251
|
end
|
|
89
252
|
|
|
253
|
+
# Positional args may be files or directories. Anything else is an error:
|
|
254
|
+
# a mistyped path that silently matched nothing produced a false green
|
|
255
|
+
# (0 subjects, exit 0) — see #23.
|
|
256
|
+
def expand_path_arg(path)
|
|
257
|
+
full = File.expand_path(path, @config.root)
|
|
258
|
+
if File.file?(full)
|
|
259
|
+
raise Error, "not a Ruby file: #{path}" unless full.end_with?(".rb")
|
|
260
|
+
|
|
261
|
+
[full]
|
|
262
|
+
elsif Dir.exist?(full)
|
|
263
|
+
Dir[File.join(full, "**", "*.rb")]
|
|
264
|
+
else
|
|
265
|
+
raise Error, "no such file or directory: #{path}"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def excluded?(file)
|
|
270
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
271
|
+
relative = file.delete_prefix(@config.root.chomp("/") + "/")
|
|
272
|
+
@config.exclude.any? do |pattern|
|
|
273
|
+
# Gitignore-like ergonomics: "lib/gen", "lib/gen/" and "lib/gen/**"
|
|
274
|
+
# all exclude the whole subtree, not just direct children.
|
|
275
|
+
dir = pattern.sub(%r{(/\*\*)?/?\z}, "")
|
|
276
|
+
File.fnmatch?(pattern, relative, flags) ||
|
|
277
|
+
File.fnmatch?("#{dir}/**/*", relative, flags)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
90
281
|
def default_paths
|
|
91
282
|
%w[app lib].select { |p| Dir.exist?(File.join(@config.root, p)) }
|
|
92
283
|
end
|
|
@@ -127,17 +318,46 @@ module ActiveMutator
|
|
|
127
318
|
SimpleCov.at_exit {} if defined?(SimpleCov)
|
|
128
319
|
end
|
|
129
320
|
|
|
130
|
-
|
|
321
|
+
# Only a run with no subject-level narrowing has fully scanned a file;
|
|
322
|
+
# anything narrower must not prune (or warn about) out-of-scope entries.
|
|
323
|
+
# MAINTENANCE: any future flag that narrows the mutant set below "every
|
|
324
|
+
# subject in the scanned files" MUST be added to this nil-trigger list,
|
|
325
|
+
# or scoped accept runs will clobber out-of-scope ledger entries (#24).
|
|
326
|
+
# --no-class-level drops every class_body subject (discover_subjects), so a
|
|
327
|
+
# file's class-body fingerprint is absent even though the file is scanned;
|
|
328
|
+
# without this guard its accepted ledger entry looks stale and gets pruned.
|
|
329
|
+
def prune_scope(subjects)
|
|
330
|
+
return nil if @config.subject_filter || @config.since || @config.max_mutants || !@config.class_level
|
|
331
|
+
|
|
332
|
+
subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def accept_survivors!(ledger, results, fingerprints, scanned_files)
|
|
131
336
|
survivors = results.select { |r| r.status == :survived }.map { |r| fingerprints[r.mutation] }
|
|
132
337
|
return if survivors.empty?
|
|
133
338
|
|
|
134
|
-
ledger.accept!(survivors, fingerprints.values)
|
|
339
|
+
ledger.accept!(survivors, fingerprints.values, scanned_files: scanned_files)
|
|
135
340
|
end
|
|
136
341
|
|
|
137
|
-
def
|
|
138
|
-
|
|
342
|
+
def debug_plan(items, pre_results)
|
|
343
|
+
plan = items.map do |i|
|
|
344
|
+
{ "subject" => i.mutation.subject.name, "description" => i.mutation.description,
|
|
345
|
+
"file" => i.mutation.subject.file, "line" => i.mutation.line,
|
|
346
|
+
"lane" => i.lane.to_s, "timeout" => i.timeout.round(2),
|
|
347
|
+
"examples" => i.example_ids.size }
|
|
348
|
+
end
|
|
349
|
+
skipped = pre_results.group_by { |r| r.status.to_s }.transform_values(&:size)
|
|
350
|
+
puts JSON.pretty_generate("planned" => plan, "pre_resolved" => skipped)
|
|
351
|
+
0
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def warn_stale(ledger, all_fingerprints, scanned_files)
|
|
355
|
+
ledger.stale_entries(all_fingerprints, scanned_files: scanned_files).each do |entry|
|
|
139
356
|
warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject}, #{entry.description}"
|
|
140
357
|
end
|
|
358
|
+
ledger.missing_file_entries(@config.root).each do |entry|
|
|
359
|
+
warn "active_mutator: accepted fingerprint references missing file: #{entry.file} (#{entry.subject})"
|
|
360
|
+
end
|
|
141
361
|
end
|
|
142
362
|
end
|
|
143
363
|
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,17 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
|
-
# A mutable unit: one method definition
|
|
3
|
-
# byte_range/line_range cover the whole `def ... end
|
|
4
|
-
|
|
2
|
+
# A mutable unit. kind :instance/:singleton = one method definition
|
|
3
|
+
# (byte_range/line_range cover the whole `def ... end`). kind :class_body =
|
|
4
|
+
# the class-level code of one class/module (byte_range covers the whole
|
|
5
|
+
# class/module node; Engine only mutates non-def body statements).
|
|
6
|
+
# sclass: def lives inside `class << self` — its source slice is `def foo`,
|
|
7
|
+
# so Inserter must target the singleton class, not the constant itself.
|
|
8
|
+
Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass) do
|
|
9
|
+
def initialize(name:, file:, byte_range:, line_range:, constant_scope:, kind:, sclass: false)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
5
13
|
def singleton? = kind == :singleton
|
|
14
|
+
|
|
15
|
+
def class_body? = kind == :class_body
|
|
6
16
|
end
|
|
7
17
|
end
|