sixth_sense 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/exe/sixth_sense +7 -0
- data/lib/sixth_sense/adapters/minitest.rb +145 -0
- data/lib/sixth_sense/adapters/rspec.rb +373 -0
- data/lib/sixth_sense/adapters/test_unit.rb +142 -0
- data/lib/sixth_sense/analysis_context.rb +35 -0
- data/lib/sixth_sense/analysis_runner.rb +141 -0
- data/lib/sixth_sense/analyzer.rb +85 -0
- data/lib/sixth_sense/analyzers/adequacy_checked_coverage.rb +39 -0
- data/lib/sixth_sense/analyzers/adequacy_coverage.rb +63 -0
- data/lib/sixth_sense/analyzers/adequacy_mutation.rb +141 -0
- data/lib/sixth_sense/analyzers/quality_assertion_density.rb +41 -0
- data/lib/sixth_sense/analyzers/quality_flakiness.rb +53 -0
- data/lib/sixth_sense/analyzers/quality_test_smells.rb +253 -0
- data/lib/sixth_sense/analyzers/redundancy_clone.rb +54 -0
- data/lib/sixth_sense/analyzers/redundancy_coverage.rb +39 -0
- data/lib/sixth_sense/analyzers/redundancy_mutation.rb +39 -0
- data/lib/sixth_sense/analyzers/redundancy_requirement.rb +70 -0
- data/lib/sixth_sense/changed_files.rb +45 -0
- data/lib/sixth_sense/cli.rb +229 -0
- data/lib/sixth_sense/config.rb +91 -0
- data/lib/sixth_sense/engines/mutant.rb +258 -0
- data/lib/sixth_sense/framework_adapter.rb +55 -0
- data/lib/sixth_sense/guardrail/baseline.rb +135 -0
- data/lib/sixth_sense/guardrail/evaluator.rb +69 -0
- data/lib/sixth_sense/model.rb +264 -0
- data/lib/sixth_sense/mutation_cache.rb +93 -0
- data/lib/sixth_sense/mutation_engine.rb +52 -0
- data/lib/sixth_sense/mutation_matrix_mutant_generator.rb +462 -0
- data/lib/sixth_sense/mutation_matrix_producer.rb +308 -0
- data/lib/sixth_sense/mutation_score_cache_writer.rb +75 -0
- data/lib/sixth_sense/rake_task.rb +16 -0
- data/lib/sixth_sense/reporters/console.rb +31 -0
- data/lib/sixth_sense/reporters/html.rb +62 -0
- data/lib/sixth_sense/reporters/json.rb +18 -0
- data/lib/sixth_sense/reporters/markdown.rb +34 -0
- data/lib/sixth_sense/reporters/sarif.rb +77 -0
- data/lib/sixth_sense/result.rb +86 -0
- data/lib/sixth_sense/runners/checked_coverage_estimator.rb +62 -0
- data/lib/sixth_sense/runners/checked_coverage_runner.rb +130 -0
- data/lib/sixth_sense/runners/checked_coverage_trace.rb +110 -0
- data/lib/sixth_sense/runners/coverage_runner.rb +220 -0
- data/lib/sixth_sense/runners/coverage_snapshot.rb +64 -0
- data/lib/sixth_sense/runners/minitest_checked_coverage_probe.rb +42 -0
- data/lib/sixth_sense/runners/minitest_coverage_probe.rb +49 -0
- data/lib/sixth_sense/runners/rspec_checked_coverage_probe.rb +26 -0
- data/lib/sixth_sense/runners/rspec_coverage_probe.rb +98 -0
- data/lib/sixth_sense/runners/test_unit_checked_coverage_probe.rb +43 -0
- data/lib/sixth_sense/runners/test_unit_coverage_probe.rb +51 -0
- data/lib/sixth_sense/scoring/aggregator.rb +117 -0
- data/lib/sixth_sense/source_location.rb +19 -0
- data/lib/sixth_sense/version.rb +5 -0
- data/lib/sixth_sense.rb +74 -0
- metadata +113 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module SixthSense
|
|
8
|
+
module Guardrail
|
|
9
|
+
class Baseline
|
|
10
|
+
DEFAULT_PATH = ".sixth_sense_baseline.json"
|
|
11
|
+
|
|
12
|
+
attr_reader :path, :data
|
|
13
|
+
|
|
14
|
+
def self.load(path = DEFAULT_PATH)
|
|
15
|
+
data = File.file?(path) ? JSON.parse(File.read(path)) : empty_data
|
|
16
|
+
new(path: path, data: data)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.empty_data
|
|
20
|
+
{
|
|
21
|
+
"version" => 1,
|
|
22
|
+
"generated_at" => nil,
|
|
23
|
+
"level" => 0,
|
|
24
|
+
"files" => {}
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(path:, data:)
|
|
29
|
+
@path = path
|
|
30
|
+
@data = data
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def violations(reports, current_level:, tolerance:)
|
|
34
|
+
return [] if current_level < data.fetch("level", 0).to_i
|
|
35
|
+
|
|
36
|
+
reports.flat_map do |report|
|
|
37
|
+
baseline_file = data.fetch("files", {})[report.test_file.path]
|
|
38
|
+
next [] unless baseline_file
|
|
39
|
+
next [] if baseline_file["digest"] == digest_for(report.test_file)
|
|
40
|
+
|
|
41
|
+
report.axis_scores.filter_map do |score|
|
|
42
|
+
next unless score.measured?
|
|
43
|
+
|
|
44
|
+
baseline_value = baseline_file[score.axis.to_s]
|
|
45
|
+
next unless baseline_value
|
|
46
|
+
next unless score.value < baseline_value.to_f - tolerance
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
path: report.test_file.path,
|
|
50
|
+
axis: score.axis,
|
|
51
|
+
current: score.value,
|
|
52
|
+
baseline: baseline_value.to_f,
|
|
53
|
+
tolerance: tolerance
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def warnings(reports, current_level:, tolerance:)
|
|
60
|
+
return [] if current_level < data.fetch("level", 0).to_i
|
|
61
|
+
|
|
62
|
+
reports.flat_map do |report|
|
|
63
|
+
baseline_file = data.fetch("files", {})[report.test_file.path]
|
|
64
|
+
next [] unless baseline_file
|
|
65
|
+
next [] unless baseline_file["digest"] == digest_for(report.test_file)
|
|
66
|
+
|
|
67
|
+
report.axis_scores.filter_map do |score|
|
|
68
|
+
next unless score.measured?
|
|
69
|
+
|
|
70
|
+
baseline_value = baseline_file[score.axis.to_s]
|
|
71
|
+
next unless baseline_value
|
|
72
|
+
next unless (score.value - baseline_value.to_f).abs > tolerance
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
type: :flaky_measurement,
|
|
76
|
+
path: report.test_file.path,
|
|
77
|
+
axis: score.axis,
|
|
78
|
+
current: score.value,
|
|
79
|
+
baseline: baseline_value.to_f,
|
|
80
|
+
tolerance: tolerance
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def update(reports, level:, force: false)
|
|
87
|
+
return self if !force && data.fetch("level", 0).to_i > level
|
|
88
|
+
|
|
89
|
+
next_data = self.class.empty_data
|
|
90
|
+
next_data["generated_at"] = Time.now.utc.iso8601
|
|
91
|
+
next_data["level"] = level
|
|
92
|
+
reports.each do |report|
|
|
93
|
+
existing = data.fetch("files", {})[report.test_file.path] || {}
|
|
94
|
+
next_data["files"][report.test_file.path] = updated_entry(report, existing, force: force)
|
|
95
|
+
end
|
|
96
|
+
@data = next_data
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write
|
|
100
|
+
File.write(path, JSON.pretty_generate(data) + "\n")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def digest_for(test_file)
|
|
104
|
+
digest = Digest::SHA256.new
|
|
105
|
+
([test_file.path] + test_file.sut_candidates.map(&:path)).uniq.each do |path|
|
|
106
|
+
next unless File.file?(path)
|
|
107
|
+
|
|
108
|
+
digest.update(path)
|
|
109
|
+
digest.update(File.binread(path))
|
|
110
|
+
end
|
|
111
|
+
"sha256:#{digest.hexdigest}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def updated_entry(report, existing, force:)
|
|
117
|
+
entry = {
|
|
118
|
+
"digest" => digest_for(report.test_file)
|
|
119
|
+
}
|
|
120
|
+
report.axis_scores.each do |score|
|
|
121
|
+
next unless score.measured?
|
|
122
|
+
|
|
123
|
+
old_value = existing[score.axis.to_s]
|
|
124
|
+
entry[score.axis.to_s] = if old_value && !force
|
|
125
|
+
[old_value.to_f, score.value].max.round(2)
|
|
126
|
+
else
|
|
127
|
+
score.value.round(2)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
entry
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "baseline"
|
|
4
|
+
|
|
5
|
+
module SixthSense
|
|
6
|
+
module Guardrail
|
|
7
|
+
Evaluation = Struct.new(:passed, :violations, :baseline_violations, :warnings, keyword_init: true) do
|
|
8
|
+
def passed?
|
|
9
|
+
passed
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Evaluator
|
|
14
|
+
def initialize(config:, baseline: Baseline.load)
|
|
15
|
+
@config = config
|
|
16
|
+
@baseline = baseline
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def evaluate(reports, level:)
|
|
20
|
+
threshold_violations = threshold_violations(reports, level: level)
|
|
21
|
+
tolerance = @config.fetch(:guardrail, :tolerance, default: 1.0).to_f
|
|
22
|
+
baseline_violations = ratchet? ? @baseline.violations(reports, current_level: level, tolerance: tolerance) : []
|
|
23
|
+
warnings = ratchet? ? @baseline.warnings(reports, current_level: level, tolerance: tolerance) : []
|
|
24
|
+
|
|
25
|
+
Evaluation.new(
|
|
26
|
+
passed: threshold_violations.empty? && baseline_violations.empty?,
|
|
27
|
+
violations: threshold_violations,
|
|
28
|
+
baseline_violations: baseline_violations,
|
|
29
|
+
warnings: warnings
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def threshold_violations(reports, level:)
|
|
36
|
+
require_level = @config.fetch(:guardrail, :require_level, default: 0).to_i
|
|
37
|
+
level_violation = if level < require_level
|
|
38
|
+
[{ type: :level, current: level, required: require_level }]
|
|
39
|
+
else
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
fail_under = @config.fetch(:guardrail, :fail_under, default: {})
|
|
44
|
+
score_violations = reports.flat_map do |report|
|
|
45
|
+
report.axis_scores.filter_map do |score|
|
|
46
|
+
threshold = fail_under[score.axis.to_s] || fail_under[score.axis]
|
|
47
|
+
next unless threshold
|
|
48
|
+
next unless !score.measured? || score.value < threshold.to_f
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
type: :threshold,
|
|
52
|
+
path: report.test_file.path,
|
|
53
|
+
axis: score.axis,
|
|
54
|
+
current: score.value,
|
|
55
|
+
threshold: threshold.to_f,
|
|
56
|
+
measured: score.measured?
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
level_violation + score_violations
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def ratchet?
|
|
65
|
+
@config.fetch(:guardrail, :ratchet, default: false)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "source_location"
|
|
6
|
+
|
|
7
|
+
module SixthSense
|
|
8
|
+
module Model
|
|
9
|
+
TestFile = Struct.new(
|
|
10
|
+
:path,
|
|
11
|
+
:framework,
|
|
12
|
+
:test_cases,
|
|
13
|
+
:sut_candidates,
|
|
14
|
+
:metadata,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
) do
|
|
17
|
+
def assertions
|
|
18
|
+
test_cases.flat_map(&:assertions)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def executable_loc
|
|
22
|
+
test_cases.sum(&:executable_loc)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
path: path,
|
|
28
|
+
framework: framework,
|
|
29
|
+
test_cases: test_cases.map(&:to_h),
|
|
30
|
+
sut_candidates: sut_candidates.map(&:to_h),
|
|
31
|
+
metadata: metadata || {}
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
TestCase = Struct.new(
|
|
37
|
+
:id,
|
|
38
|
+
:description,
|
|
39
|
+
:location,
|
|
40
|
+
:assertions,
|
|
41
|
+
:ast,
|
|
42
|
+
:body,
|
|
43
|
+
:metadata,
|
|
44
|
+
keyword_init: true
|
|
45
|
+
) do
|
|
46
|
+
def executable_loc
|
|
47
|
+
body.to_s.lines.count { |line| line.match?(/\S/) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_h
|
|
51
|
+
{
|
|
52
|
+
id: id,
|
|
53
|
+
description: description,
|
|
54
|
+
location: location&.to_h,
|
|
55
|
+
assertions: assertions.map(&:to_h),
|
|
56
|
+
metadata: metadata
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Assertion = Struct.new(
|
|
62
|
+
:location,
|
|
63
|
+
:matcher,
|
|
64
|
+
:subject_expr,
|
|
65
|
+
:message,
|
|
66
|
+
keyword_init: true
|
|
67
|
+
) do
|
|
68
|
+
def signature
|
|
69
|
+
[matcher.to_s, subject_expr.to_s.gsub(/\s+/, " ").strip].join(":")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_h
|
|
73
|
+
{
|
|
74
|
+
location: location&.to_h,
|
|
75
|
+
matcher: matcher,
|
|
76
|
+
subject_expr: subject_expr,
|
|
77
|
+
message: message
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
CodeUnit = Struct.new(:path, :constants, :methods, keyword_init: true) do
|
|
83
|
+
def to_h
|
|
84
|
+
{
|
|
85
|
+
path: path,
|
|
86
|
+
constants: constants,
|
|
87
|
+
methods: methods
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
CoverageMap = Struct.new(
|
|
93
|
+
:per_test_lines,
|
|
94
|
+
:per_test_branches,
|
|
95
|
+
:total_lines,
|
|
96
|
+
:total_branches,
|
|
97
|
+
keyword_init: true
|
|
98
|
+
) do
|
|
99
|
+
# Paper basis: Zhu/Hall/May 1997 (doi:10.1145/267580.267590)
|
|
100
|
+
# treats executed lines as coverage requirements for adequacy criteria.
|
|
101
|
+
def line_requirements_for(test_case_id)
|
|
102
|
+
normalize_requirements(per_test_lines.fetch(test_case_id, {}))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Paper basis: Zhu/Hall/May 1997; branch requirements provide a stronger
|
|
106
|
+
# structural adequacy signal than line requirements when available.
|
|
107
|
+
def branch_requirements_for(test_case_id)
|
|
108
|
+
normalize_requirements(per_test_branches.fetch(test_case_id, {}))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Paper basis: Zhu/Hall/May 1997; this is the raw satisfied-line
|
|
112
|
+
# requirement ratio used by adequacy/coverage.
|
|
113
|
+
def line_coverage_ratio
|
|
114
|
+
ratio_for(per_test_lines, total_lines)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Paper basis: Zhu/Hall/May 1997; this is the raw satisfied-branch
|
|
118
|
+
# requirement ratio preferred by adequacy/coverage.
|
|
119
|
+
def branch_coverage_ratio
|
|
120
|
+
ratio_for(per_test_branches, total_branches)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def ratio_for(data, totals)
|
|
126
|
+
covered_requirements = data.values.flat_map do |requirements_by_path|
|
|
127
|
+
normalize_requirements(requirements_by_path).to_a
|
|
128
|
+
end.to_set
|
|
129
|
+
covered = covered_requirements.length
|
|
130
|
+
total = totals ? normalize_requirements(totals).length : covered
|
|
131
|
+
return nil if total.zero?
|
|
132
|
+
|
|
133
|
+
covered.to_f / total
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_requirements(requirements_by_path)
|
|
137
|
+
requirements_by_path.flat_map do |path, requirements|
|
|
138
|
+
requirements.map { |requirement| [path, requirement] }
|
|
139
|
+
end.to_set
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
CheckedCoverageMap = Struct.new(:per_test_checked_lines, :executed_lines, keyword_init: true) do
|
|
144
|
+
# Paper basis: Schuler/Zeller 2011 (doi:10.1109/ICST.2011.32)
|
|
145
|
+
# scores the fraction of executed code that is checked by an oracle.
|
|
146
|
+
def checked_ratio
|
|
147
|
+
executed = normalize_requirements(executed_lines || {})
|
|
148
|
+
return nil if executed.empty?
|
|
149
|
+
|
|
150
|
+
checked = per_test_checked_lines.values.flat_map do |requirements_by_path|
|
|
151
|
+
normalize_requirements(requirements_by_path).to_a
|
|
152
|
+
end.to_set
|
|
153
|
+
(checked & executed).length.to_f / executed.length
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Paper basis: Schuler/Zeller 2011; unchecked executed lines are reported
|
|
157
|
+
# as oracle-quality gaps rather than ordinary coverage gaps.
|
|
158
|
+
def unchecked_lines
|
|
159
|
+
checked = per_test_checked_lines.values.flat_map do |requirements_by_path|
|
|
160
|
+
normalize_requirements(requirements_by_path).to_a
|
|
161
|
+
end.to_set
|
|
162
|
+
normalize_requirements(executed_lines || {}) - checked
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def normalize_requirements(requirements_by_path)
|
|
168
|
+
requirements_by_path.flat_map do |path, requirements|
|
|
169
|
+
requirements.map { |requirement| [path, requirement] }
|
|
170
|
+
end.to_set
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
Mutant = Struct.new(
|
|
175
|
+
:id,
|
|
176
|
+
:operator,
|
|
177
|
+
:location,
|
|
178
|
+
:status,
|
|
179
|
+
:diff,
|
|
180
|
+
:killing_hint,
|
|
181
|
+
:metadata,
|
|
182
|
+
keyword_init: true
|
|
183
|
+
) do
|
|
184
|
+
# Paper basis: Jia/Harman 2011 (doi:10.1109/TSE.2010.62) counts killed
|
|
185
|
+
# mutants as behaviorally distinguished by the test suite.
|
|
186
|
+
def killed?
|
|
187
|
+
return timeout_killed? if status == :timeout || status == "timeout"
|
|
188
|
+
|
|
189
|
+
status == :killed || status == "killed" || status == :timeout
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Paper basis: Jia/Harman 2011 describes equivalent mutants as a threat;
|
|
193
|
+
# equivalent/skipped mutants are excluded from the mutation-score denominator.
|
|
194
|
+
def excluded?
|
|
195
|
+
status == :excluded || status == "excluded" || status == :equivalent || status == :skipped || status == "skipped"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def to_h
|
|
199
|
+
{
|
|
200
|
+
id: id,
|
|
201
|
+
operator: operator,
|
|
202
|
+
location: location&.to_h,
|
|
203
|
+
status: status,
|
|
204
|
+
diff: diff,
|
|
205
|
+
killing_hint: killing_hint,
|
|
206
|
+
metadata: metadata || {}
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def timeout_killed?
|
|
213
|
+
(metadata || {}).fetch("timeout_policy", "killed") == "killed"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
KillMatrix = Struct.new(:mutants, :kills, :metadata, keyword_init: true) do
|
|
218
|
+
# Paper basis: Jia/Harman 2011 defines mutation score as killed mutants
|
|
219
|
+
# over non-excluded mutants; Just et al. 2014 validates its fault relation.
|
|
220
|
+
def mutation_score
|
|
221
|
+
override = (metadata || {}).dig("summary", "mutation_score")
|
|
222
|
+
return override.to_f if override
|
|
223
|
+
|
|
224
|
+
countable = mutants.reject(&:excluded?)
|
|
225
|
+
return nil if countable.empty?
|
|
226
|
+
|
|
227
|
+
countable.count(&:killed?).to_f / countable.length
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def matrix?
|
|
231
|
+
(metadata || {}).fetch("mode", "matrix") == "matrix"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def surviving_mutants
|
|
235
|
+
mutants.reject(&:excluded?).reject(&:killed?)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def timeout_ratio
|
|
239
|
+
countable = mutants.reject(&:excluded?)
|
|
240
|
+
return 0.0 if countable.empty?
|
|
241
|
+
|
|
242
|
+
countable.count { |mutant| mutant.status == :timeout || mutant.status == "timeout" }.to_f / countable.length
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def sampled?
|
|
246
|
+
!!(metadata || {}).dig("sampling", "sampled")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Paper basis: Jia/Harman 2011 highlights equivalent mutants; the ratio is
|
|
250
|
+
# surfaced to warn about excessive denominator exclusions.
|
|
251
|
+
def equivalent_ratio
|
|
252
|
+
return 0.0 if mutants.empty?
|
|
253
|
+
|
|
254
|
+
mutants.count { |mutant| mutant.status == :equivalent || mutant.status == "equivalent" }.to_f / mutants.length
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Paper basis: Koochakzadeh/Garousi 2010 uses killed-mutant sets as
|
|
258
|
+
# requirements for mutation-based redundancy detection.
|
|
259
|
+
def kill_requirements_for(test_case_id)
|
|
260
|
+
kills.fetch(test_case_id, Set.new).to_set
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
require_relative "model"
|
|
8
|
+
require_relative "source_location"
|
|
9
|
+
|
|
10
|
+
module SixthSense
|
|
11
|
+
class MutationCache
|
|
12
|
+
DEFAULT_ROOT = ".sixth_sense_cache/kill_matrix"
|
|
13
|
+
|
|
14
|
+
def initialize(root: DEFAULT_ROOT)
|
|
15
|
+
@root = root
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def load(test_file)
|
|
19
|
+
path = path_for(test_file.path)
|
|
20
|
+
return nil unless File.file?(path)
|
|
21
|
+
|
|
22
|
+
build_matrix(JSON.parse(File.read(path)), test_file)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write(test_file, payload)
|
|
26
|
+
FileUtils.mkdir_p(@root)
|
|
27
|
+
File.write(path_for(test_file.path), JSON.pretty_generate(payload) + "\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def path_for(test_file_path)
|
|
31
|
+
File.join(@root, "#{test_file_path.gsub(%r{[^A-Za-z0-9]+}, "_").gsub(/\A_+|_+\z/, "")}.json")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_matrix(payload, test_file)
|
|
37
|
+
equivalent_ids = equivalent_annotations(test_file)
|
|
38
|
+
mutants = []
|
|
39
|
+
kills = Hash.new { |hash, key| hash[key] = Set.new }
|
|
40
|
+
|
|
41
|
+
payload.fetch("subjects", []).each do |subject|
|
|
42
|
+
subject.fetch("mutants", []).each do |entry|
|
|
43
|
+
status = equivalent_ids.include?(entry.fetch("id")) ? :equivalent : entry.fetch("status", "alive").to_sym
|
|
44
|
+
mutant = Model::Mutant.new(
|
|
45
|
+
id: entry.fetch("id"),
|
|
46
|
+
operator: entry["operator"],
|
|
47
|
+
location: location_for(entry["location"]),
|
|
48
|
+
status: status,
|
|
49
|
+
diff: entry["diff"],
|
|
50
|
+
killing_hint: entry["killing_hint"],
|
|
51
|
+
metadata: metadata_for(entry, payload)
|
|
52
|
+
)
|
|
53
|
+
mutants << mutant
|
|
54
|
+
Array(entry["killed_by"]).each { |test_case_id| kills[test_case_id] << mutant.id }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Model::KillMatrix.new(mutants: mutants, kills: kills, metadata: matrix_metadata(payload))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def location_for(payload)
|
|
62
|
+
return nil unless payload
|
|
63
|
+
|
|
64
|
+
SourceLocation.new(path: payload["file"], line: payload["line"], column: payload["column"] || 1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def equivalent_annotations(test_file)
|
|
68
|
+
test_file.sut_candidates.flat_map do |unit|
|
|
69
|
+
next [] unless File.file?(unit.path)
|
|
70
|
+
|
|
71
|
+
File.read(unit.path).scan(/#\s*sixth_sense:equivalent\s+([A-Za-z0-9_.:-]+)/).flatten
|
|
72
|
+
end.to_set
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def metadata_for(entry, payload)
|
|
76
|
+
(entry["metadata"] || {}).merge(
|
|
77
|
+
"timeout_policy" => payload.dig("metadata", "timeout_policy") || "killed",
|
|
78
|
+
"timed_out" => Array(entry["timed_out"])
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def matrix_metadata(payload)
|
|
83
|
+
{
|
|
84
|
+
"engine" => payload["engine"],
|
|
85
|
+
"mode" => payload["mode"] || "matrix",
|
|
86
|
+
"summary" => payload["summary"] || {},
|
|
87
|
+
"sampling" => payload.dig("metadata", "sampling") || {},
|
|
88
|
+
"timeout_policy" => payload.dig("metadata", "timeout_policy") || "killed",
|
|
89
|
+
"warnings" => payload.dig("metadata", "warnings") || []
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SixthSense
|
|
4
|
+
Availability = Struct.new(:available, :reason, keyword_init: true) do
|
|
5
|
+
def available?
|
|
6
|
+
available
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
MutationPlan = Struct.new(:engine, :mode, :subjects, :test_files, :source_units, keyword_init: true) do
|
|
11
|
+
def to_h
|
|
12
|
+
{
|
|
13
|
+
engine: engine,
|
|
14
|
+
mode: mode,
|
|
15
|
+
subjects: subjects,
|
|
16
|
+
test_files: test_files,
|
|
17
|
+
source_units: source_units
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
MutationRunResult = Struct.new(:plan, :command, :success, :stdout, :stderr, :status, keyword_init: true) do
|
|
23
|
+
def success?
|
|
24
|
+
success
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{
|
|
29
|
+
plan: plan.to_h,
|
|
30
|
+
command: command,
|
|
31
|
+
success: success,
|
|
32
|
+
stdout: stdout,
|
|
33
|
+
stderr: stderr,
|
|
34
|
+
status: status
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class MutationEngine
|
|
40
|
+
def plan(sut_units:, test_files:)
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run(_plan, cache:)
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def availability
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|