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,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../framework_adapter"
|
|
6
|
+
require_relative "../model"
|
|
7
|
+
require_relative "../source_location"
|
|
8
|
+
require_relative "rspec"
|
|
9
|
+
|
|
10
|
+
module SixthSense
|
|
11
|
+
module Adapters
|
|
12
|
+
class TestUnit < FrameworkAdapter
|
|
13
|
+
ASSERTION_PREFIXES = %w[assert assert_ flunk].freeze
|
|
14
|
+
|
|
15
|
+
def handles?(path)
|
|
16
|
+
return false unless path.end_with?("_test.rb")
|
|
17
|
+
return false unless File.file?(path)
|
|
18
|
+
|
|
19
|
+
File.read(path).match?(/\bTest::Unit::TestCase\b/)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse(path)
|
|
23
|
+
parsed = Prism.parse_file(path)
|
|
24
|
+
unless parsed.success?
|
|
25
|
+
messages = parsed.errors.map(&:message).join(", ")
|
|
26
|
+
raise Error, "failed to parse #{path}: #{messages}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Model::TestFile.new(
|
|
30
|
+
path: path,
|
|
31
|
+
framework: :test_unit,
|
|
32
|
+
test_cases: Parser.new(path, parsed.value).test_cases,
|
|
33
|
+
sut_candidates: map_to_sut_path(path),
|
|
34
|
+
metadata: {}
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_with_coverage(test_files:, isolation:)
|
|
39
|
+
require_relative "../runners/coverage_runner"
|
|
40
|
+
|
|
41
|
+
Runners::CoverageRunner.new.run(test_files: test_files, isolation: isolation)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def mutation_session(test_files:)
|
|
45
|
+
{ framework: :test_unit, test_files: test_files.map(&:path), runner: "ruby" }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def map_to_sut(test_file)
|
|
49
|
+
map_to_sut_path(test_file.path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def map_to_sut_path(test_path)
|
|
55
|
+
relative = test_path.tr("\\", "/").sub(%r{\A(?:.*?/)?(?:test|test_unit)/}, "").sub(/_test\.rb\z/, ".rb")
|
|
56
|
+
root = project_root_for(test_path)
|
|
57
|
+
candidates = [File.join("lib", relative), File.join("app", relative), relative]
|
|
58
|
+
if root
|
|
59
|
+
candidates << File.join(root, "lib", relative)
|
|
60
|
+
candidates << File.join(root, "app", relative)
|
|
61
|
+
end
|
|
62
|
+
candidates.uniq.filter_map do |candidate|
|
|
63
|
+
next unless File.file?(candidate)
|
|
64
|
+
|
|
65
|
+
Adapters::RSpec::CodeUnitExtractor.new(candidate).code_unit
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def project_root_for(test_path)
|
|
70
|
+
normalized = test_path.tr("\\", "/")
|
|
71
|
+
match = %r{\A(.+?)/(?:test|test_unit)/}.match(normalized)
|
|
72
|
+
match&.[](1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class Parser
|
|
76
|
+
def initialize(path, ast)
|
|
77
|
+
@path = path
|
|
78
|
+
@ast = ast
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_cases
|
|
82
|
+
cases = []
|
|
83
|
+
walk(@ast) do |node|
|
|
84
|
+
next unless node.is_a?(Prism::DefNode)
|
|
85
|
+
next unless node.name.to_s.start_with?("test_")
|
|
86
|
+
|
|
87
|
+
cases << test_case_for(node)
|
|
88
|
+
end
|
|
89
|
+
cases
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def test_case_for(node)
|
|
95
|
+
body = node.slice.to_s
|
|
96
|
+
Model::TestCase.new(
|
|
97
|
+
id: "#{@path}:#{node.name}",
|
|
98
|
+
description: node.name.to_s.tr("_", " "),
|
|
99
|
+
location: location_for(node),
|
|
100
|
+
assertions: assertions_for(node),
|
|
101
|
+
ast: node,
|
|
102
|
+
body: body,
|
|
103
|
+
metadata: {}
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def assertions_for(node)
|
|
108
|
+
assertions = []
|
|
109
|
+
walk(node) do |child|
|
|
110
|
+
next unless child.is_a?(Prism::CallNode)
|
|
111
|
+
next unless assertion_call?(child.name)
|
|
112
|
+
|
|
113
|
+
assertions << Model::Assertion.new(
|
|
114
|
+
location: location_for(child),
|
|
115
|
+
matcher: child.name,
|
|
116
|
+
subject_expr: child.arguments&.arguments&.map(&:slice)&.join(", "),
|
|
117
|
+
message: nil
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
assertions
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def assertion_call?(name)
|
|
124
|
+
ASSERTION_PREFIXES.any? { |prefix| name.to_s.start_with?(prefix) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def walk(node, &block)
|
|
128
|
+
return unless node
|
|
129
|
+
|
|
130
|
+
yield node
|
|
131
|
+
node.each_child_node { |child| walk(child, &block) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def location_for(node)
|
|
135
|
+
SourceLocation.new(path: @path, line: node.location.start_line, column: node.location.start_column + 1)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
SixthSense::FrameworkAdapter.register(:test_unit, SixthSense::Adapters::TestUnit)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SixthSense
|
|
4
|
+
class AnalysisContext
|
|
5
|
+
attr_reader :config, :coverage_maps, :checked_coverage_maps, :kill_matrices, :warnings
|
|
6
|
+
|
|
7
|
+
def initialize(config: {}, coverage_maps: {}, checked_coverage_maps: {}, kill_matrices: {}, warnings: [])
|
|
8
|
+
@config = config
|
|
9
|
+
@coverage_maps = coverage_maps
|
|
10
|
+
@checked_coverage_maps = checked_coverage_maps
|
|
11
|
+
@kill_matrices = kill_matrices
|
|
12
|
+
@warnings = warnings
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def coverage_map_for(test_file)
|
|
16
|
+
coverage_maps[test_file.path]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def checked_coverage_map_for(test_file)
|
|
20
|
+
checked_coverage_maps[test_file.path]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def kill_matrix_for(test_file)
|
|
24
|
+
kill_matrices[test_file.path]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def config_fetch(*keys, default: nil)
|
|
28
|
+
keys.reduce(config) do |current, key|
|
|
29
|
+
break default unless current.respond_to?(:fetch)
|
|
30
|
+
|
|
31
|
+
current.fetch(key.to_s) { current.fetch(key.to_sym, default) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "analysis_context"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "framework_adapter"
|
|
6
|
+
require_relative "model"
|
|
7
|
+
require_relative "mutation_cache"
|
|
8
|
+
require_relative "runners/checked_coverage_estimator"
|
|
9
|
+
require_relative "runners/checked_coverage_runner"
|
|
10
|
+
require_relative "runners/coverage_runner"
|
|
11
|
+
require_relative "scoring/aggregator"
|
|
12
|
+
|
|
13
|
+
module SixthSense
|
|
14
|
+
class AnalysisRunner
|
|
15
|
+
attr_reader :config
|
|
16
|
+
|
|
17
|
+
def initialize(config: Config.load)
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def analyze(paths:, level:)
|
|
22
|
+
warnings = []
|
|
23
|
+
test_files = discover(paths).filter_map do |path|
|
|
24
|
+
adapter_class = FrameworkAdapter.for_path(path)
|
|
25
|
+
unless adapter_class
|
|
26
|
+
warnings << "No framework adapter accepted #{path}."
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
apply_configured_mapping(adapter_class.new.parse(path))
|
|
31
|
+
rescue Error => error
|
|
32
|
+
warnings << "#{path}: #{error.class}: #{error.message}"
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
coverage_maps = collect_coverage(test_files, level, warnings)
|
|
37
|
+
checked_maps = collect_checked_coverage(test_files, coverage_maps, level, warnings)
|
|
38
|
+
kill_matrices = collect_mutation(test_files, level, warnings)
|
|
39
|
+
context = AnalysisContext.new(
|
|
40
|
+
config: config.data,
|
|
41
|
+
coverage_maps: coverage_maps,
|
|
42
|
+
checked_coverage_maps: checked_maps,
|
|
43
|
+
kill_matrices: kill_matrices,
|
|
44
|
+
warnings: warnings
|
|
45
|
+
)
|
|
46
|
+
aggregator = Scoring::Aggregator.new(level: level)
|
|
47
|
+
test_files.map { |test_file| aggregator.analyze(test_file, context) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def discover(paths)
|
|
51
|
+
selected = paths.empty? ? ["spec"] : paths
|
|
52
|
+
selected.flat_map { |path| expand_path(path) }.uniq.sort
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def apply_configured_mapping(test_file)
|
|
58
|
+
mapping = config.fetch(:mapping, test_file.path, default: nil)
|
|
59
|
+
return test_file unless mapping
|
|
60
|
+
|
|
61
|
+
explicit_paths = Array(mapping["paths"] || mapping[:paths])
|
|
62
|
+
explicit_units = explicit_paths.filter_map do |path|
|
|
63
|
+
next unless File.file?(path)
|
|
64
|
+
|
|
65
|
+
Adapters::RSpec::CodeUnitExtractor.new(path).code_unit
|
|
66
|
+
end
|
|
67
|
+
test_file.sut_candidates = explicit_units unless explicit_units.empty?
|
|
68
|
+
test_file.metadata ||= {}
|
|
69
|
+
test_file.metadata[:mutation_subjects] = Array(mapping["subjects"] || mapping[:subjects])
|
|
70
|
+
test_file
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def collect_coverage(test_files, level, warnings)
|
|
74
|
+
return {} if level < 1 || test_files.empty?
|
|
75
|
+
return {} unless config.fetch(:coverage, :enabled, default: true)
|
|
76
|
+
|
|
77
|
+
Runners::CoverageRunner.new(config: config.fetch(:coverage, default: {})).run(test_files: test_files)
|
|
78
|
+
rescue Error, NotImplementedError => error
|
|
79
|
+
warnings << "coverage disabled: #{error.message}"
|
|
80
|
+
{}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def collect_checked_coverage(test_files, coverage_maps, level, warnings)
|
|
84
|
+
return {} if level < 1 || test_files.empty?
|
|
85
|
+
|
|
86
|
+
traced = Runners::CheckedCoverageRunner.new(config: config.fetch(:coverage, default: {})).run(test_files: test_files)
|
|
87
|
+
estimator = Runners::CheckedCoverageEstimator.new
|
|
88
|
+
test_files.each_with_object({}) do |test_file, maps|
|
|
89
|
+
coverage_map = coverage_maps[test_file.path]
|
|
90
|
+
trace_map = traced[test_file.path]
|
|
91
|
+
estimated = coverage_map ? estimator.estimate(test_file, coverage_map) : nil
|
|
92
|
+
maps[test_file.path] = merge_checked_coverage(trace_map, estimated)
|
|
93
|
+
end.reject { |_path, map| map.checked_ratio.nil? }
|
|
94
|
+
rescue Error, NotImplementedError => error
|
|
95
|
+
warnings << "checked coverage trace disabled: #{error.message}"
|
|
96
|
+
estimator = Runners::CheckedCoverageEstimator.new
|
|
97
|
+
test_files.each_with_object({}) do |test_file, maps|
|
|
98
|
+
coverage_map = coverage_maps[test_file.path]
|
|
99
|
+
maps[test_file.path] = estimator.estimate(test_file, coverage_map) if coverage_map
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def merge_checked_coverage(trace_map, estimated)
|
|
104
|
+
return estimated unless trace_map
|
|
105
|
+
return trace_map unless estimated
|
|
106
|
+
|
|
107
|
+
checked = trace_map.per_test_checked_lines.merge(estimated.per_test_checked_lines) do |_test_id, traced_lines, estimated_lines|
|
|
108
|
+
merge_line_hashes(traced_lines, estimated_lines)
|
|
109
|
+
end
|
|
110
|
+
executed = merge_line_hashes(trace_map.executed_lines, estimated.executed_lines)
|
|
111
|
+
Model::CheckedCoverageMap.new(per_test_checked_lines: checked, executed_lines: executed)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def merge_line_hashes(left, right)
|
|
115
|
+
(left.keys + right.keys).uniq.to_h do |path|
|
|
116
|
+
[path, (Array(left[path]) + Array(right[path])).uniq]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def collect_mutation(test_files, level, warnings)
|
|
121
|
+
return {} if level < 2 || test_files.empty?
|
|
122
|
+
|
|
123
|
+
cache = MutationCache.new(root: config.fetch(:mutation, :cache_dir, default: MutationCache::DEFAULT_ROOT))
|
|
124
|
+
test_files.each_with_object({}) do |test_file, matrices|
|
|
125
|
+
matrix = cache.load(test_file)
|
|
126
|
+
if matrix
|
|
127
|
+
matrices[test_file.path] = matrix
|
|
128
|
+
else
|
|
129
|
+
warnings << "mutation cache missing for #{test_file.path}; run a matrix producer and write #{cache.path_for(test_file.path)}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def expand_path(path)
|
|
135
|
+
return [path] if File.file?(path)
|
|
136
|
+
return Dir[File.join(path, "**", "*_spec.rb")] + Dir[File.join(path, "**", "*_test.rb")] if File.directory?(path)
|
|
137
|
+
|
|
138
|
+
Dir[path]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "result"
|
|
4
|
+
|
|
5
|
+
module SixthSense
|
|
6
|
+
AnalyzerResult = Struct.new(:analyzer_id, :axis, :score, :confidence, :findings, :measured, keyword_init: true) do
|
|
7
|
+
def measured?
|
|
8
|
+
measured != false && !score.nil?
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Analyzer
|
|
13
|
+
class << self
|
|
14
|
+
def inherited(subclass)
|
|
15
|
+
super
|
|
16
|
+
SixthSense.register_analyzer(subclass)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def analyzer_id(id = nil)
|
|
20
|
+
return @analyzer_id unless id
|
|
21
|
+
|
|
22
|
+
@analyzer_id = id
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def axis(value = nil)
|
|
26
|
+
return @axis unless value
|
|
27
|
+
|
|
28
|
+
@axis = value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def level(value = nil)
|
|
32
|
+
return @level unless value
|
|
33
|
+
|
|
34
|
+
@level = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reference(authors:, title:, venue:, year:, doi: nil)
|
|
38
|
+
references << Result::Reference.new(
|
|
39
|
+
authors: authors,
|
|
40
|
+
title: title,
|
|
41
|
+
venue: venue,
|
|
42
|
+
year: year,
|
|
43
|
+
doi: doi
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def references
|
|
48
|
+
@references ||= []
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def analyze(_test_file, _context)
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def result(score:, findings:, confidence:, measured: true)
|
|
59
|
+
AnalyzerResult.new(
|
|
60
|
+
analyzer_id: self.class.analyzer_id,
|
|
61
|
+
axis: self.class.axis,
|
|
62
|
+
score: score,
|
|
63
|
+
confidence: confidence,
|
|
64
|
+
findings: findings,
|
|
65
|
+
measured: measured
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def unmeasured(confidence: :low, findings: [])
|
|
70
|
+
result(score: nil, findings: findings, confidence: confidence, measured: false)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def finding(rule_id:, severity:, location:, message:, suggestion: nil)
|
|
74
|
+
Result::Finding.new(
|
|
75
|
+
analyzer_id: self.class.analyzer_id,
|
|
76
|
+
rule_id: rule_id,
|
|
77
|
+
severity: severity,
|
|
78
|
+
location: location,
|
|
79
|
+
message: message,
|
|
80
|
+
references: self.class.references,
|
|
81
|
+
suggestion: suggestion
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../analyzer"
|
|
4
|
+
require_relative "../source_location"
|
|
5
|
+
|
|
6
|
+
module SixthSense
|
|
7
|
+
module Analyzers
|
|
8
|
+
class AdequacyCheckedCoverage < Analyzer
|
|
9
|
+
analyzer_id "adequacy/checked_coverage"
|
|
10
|
+
axis :adequacy
|
|
11
|
+
level 1
|
|
12
|
+
|
|
13
|
+
reference authors: "Schuler, D.; Zeller, A.",
|
|
14
|
+
title: "Assessing Oracle Quality with Checked Coverage",
|
|
15
|
+
venue: "ICST",
|
|
16
|
+
year: 2011,
|
|
17
|
+
doi: "10.1109/ICST.2011.32"
|
|
18
|
+
|
|
19
|
+
# Paper basis: Schuler/Zeller 2011 (doi:10.1109/ICST.2011.32)
|
|
20
|
+
# defines checked coverage as executed code associated with an oracle.
|
|
21
|
+
def analyze(test_file, context)
|
|
22
|
+
checked = context.checked_coverage_map_for(test_file)
|
|
23
|
+
return unmeasured(confidence: :low) unless checked&.checked_ratio
|
|
24
|
+
|
|
25
|
+
findings = checked.unchecked_lines.first(10).map do |path, line|
|
|
26
|
+
finding(
|
|
27
|
+
rule_id: "unchecked_executed_line",
|
|
28
|
+
severity: :info,
|
|
29
|
+
location: SourceLocation.new(path: path, line: line, column: 1),
|
|
30
|
+
message: "Executed line was not associated with an assertion subject.",
|
|
31
|
+
suggestion: "Add an assertion that observes this behavior if it is semantically important."
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
result(score: (checked.checked_ratio * 100.0).round(2), findings: findings, confidence: :medium)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../analyzer"
|
|
4
|
+
|
|
5
|
+
module SixthSense
|
|
6
|
+
module Analyzers
|
|
7
|
+
class AdequacyCoverage < Analyzer
|
|
8
|
+
analyzer_id "adequacy/coverage"
|
|
9
|
+
axis :adequacy
|
|
10
|
+
level 1
|
|
11
|
+
|
|
12
|
+
reference authors: "Zhu, H.; Hall, P. A. V.; May, J. H. R.",
|
|
13
|
+
title: "Software Unit Test Coverage and Adequacy",
|
|
14
|
+
venue: "ACM Computing Surveys",
|
|
15
|
+
year: 1997,
|
|
16
|
+
doi: "10.1145/267580.267590"
|
|
17
|
+
reference authors: "Inozemtseva, L.; Holmes, R.",
|
|
18
|
+
title: "Coverage Is Not Strongly Correlated with Test Suite Effectiveness",
|
|
19
|
+
venue: "ICSE",
|
|
20
|
+
year: 2014,
|
|
21
|
+
doi: "10.1145/2568225.2568271"
|
|
22
|
+
|
|
23
|
+
# Paper basis: Zhu/Hall/May 1997 (doi:10.1145/267580.267590)
|
|
24
|
+
# surveys coverage adequacy criteria; branch coverage is preferred when present.
|
|
25
|
+
def analyze(test_file, context)
|
|
26
|
+
coverage = context.coverage_map_for(test_file)
|
|
27
|
+
return unmeasured(confidence: :low) unless coverage
|
|
28
|
+
|
|
29
|
+
branch_ratio = coverage.branch_coverage_ratio
|
|
30
|
+
line_ratio = coverage.line_coverage_ratio
|
|
31
|
+
ratio = branch_ratio || line_ratio
|
|
32
|
+
return unmeasured(confidence: :low) unless ratio
|
|
33
|
+
|
|
34
|
+
score = (score_ratio(ratio, context) * 100.0).round(2)
|
|
35
|
+
confidence = branch_ratio ? :medium : :low
|
|
36
|
+
findings = []
|
|
37
|
+
if ratio < 0.8
|
|
38
|
+
findings << finding(
|
|
39
|
+
rule_id: "low_coverage",
|
|
40
|
+
severity: :warning,
|
|
41
|
+
location: test_file.test_cases.first&.location,
|
|
42
|
+
message: "Coverage ratio is #{(ratio * 100).round(1)}%.",
|
|
43
|
+
suggestion: "Add tests for unexecuted branches before relying on adequacy scores."
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
result(score: score, findings: findings, confidence: confidence)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Paper basis: Inozemtseva/Holmes 2014 (doi:10.1145/2568225.2568271)
|
|
53
|
+
# motivates calibrated mode; raw mode keeps the paper-exact coverage ratio.
|
|
54
|
+
def score_ratio(ratio, context)
|
|
55
|
+
transform = context.config_fetch(:adequacy, :coverage_transform, default: "raw").to_s
|
|
56
|
+
return ratio unless transform == "calibrated"
|
|
57
|
+
|
|
58
|
+
gamma = context.config_fetch(:adequacy, :coverage_gamma, default: 0.5).to_f
|
|
59
|
+
1.0 - ((1.0 - ratio)**gamma)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../analyzer"
|
|
4
|
+
|
|
5
|
+
module SixthSense
|
|
6
|
+
module Analyzers
|
|
7
|
+
class AdequacyMutation < Analyzer
|
|
8
|
+
analyzer_id "adequacy/mutation"
|
|
9
|
+
axis :adequacy
|
|
10
|
+
level 2
|
|
11
|
+
|
|
12
|
+
reference authors: "Jia, Y.; Harman, M.",
|
|
13
|
+
title: "An Analysis and Survey of the Development of Mutation Testing",
|
|
14
|
+
venue: "IEEE Transactions on Software Engineering",
|
|
15
|
+
year: 2011,
|
|
16
|
+
doi: "10.1109/TSE.2010.62"
|
|
17
|
+
reference authors: "Just, R. et al.",
|
|
18
|
+
title: "Are Mutants a Valid Substitute for Real Faults in Software Testing?",
|
|
19
|
+
venue: "FSE",
|
|
20
|
+
year: 2014,
|
|
21
|
+
doi: "10.1145/2635868.2635929"
|
|
22
|
+
|
|
23
|
+
# Paper basis: Jia/Harman 2011 (doi:10.1109/TSE.2010.62) defines
|
|
24
|
+
# mutation score; Just et al. 2014 (doi:10.1145/2635868.2635929)
|
|
25
|
+
# supports mutants as a proxy for real fault detection.
|
|
26
|
+
def analyze(test_file, context)
|
|
27
|
+
matrix = context.kill_matrix_for(test_file)
|
|
28
|
+
return no_subjects(test_file) if !matrix && test_file.sut_candidates.empty?
|
|
29
|
+
if matrix && !matrix.mutation_score
|
|
30
|
+
return result(score: nil, measured: false, confidence: :low, findings: operational_findings(matrix, context))
|
|
31
|
+
end
|
|
32
|
+
return unmeasured(confidence: :low) unless matrix&.mutation_score
|
|
33
|
+
|
|
34
|
+
findings = operational_findings(matrix, context)
|
|
35
|
+
findings.concat(matrix.surviving_mutants.map do |mutant|
|
|
36
|
+
finding(
|
|
37
|
+
rule_id: mutant.status.to_s == "timeout" ? "mutation_timeout" : "surviving_mutant",
|
|
38
|
+
severity: :warning,
|
|
39
|
+
location: mutant.location,
|
|
40
|
+
message: mutant_message(mutant),
|
|
41
|
+
suggestion: mutant_suggestion(mutant)
|
|
42
|
+
)
|
|
43
|
+
end)
|
|
44
|
+
|
|
45
|
+
result(score: (matrix.mutation_score * 100.0).round(2), findings: findings, confidence: confidence_for(matrix))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def no_subjects(test_file)
|
|
51
|
+
result(
|
|
52
|
+
score: nil,
|
|
53
|
+
measured: false,
|
|
54
|
+
confidence: :low,
|
|
55
|
+
findings: [
|
|
56
|
+
finding(
|
|
57
|
+
rule_id: "no_subjects",
|
|
58
|
+
severity: :warning,
|
|
59
|
+
location: test_file.test_cases.first&.location,
|
|
60
|
+
message: "No mutation subjects were inferred for this test file.",
|
|
61
|
+
suggestion: "Add an explicit mapping entry with mutation subjects or SUT paths."
|
|
62
|
+
)
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Paper basis: Jia/Harman 2011 discusses equivalent mutants and
|
|
68
|
+
# operational mutation-analysis threats such as timeouts/sampling.
|
|
69
|
+
def operational_findings(matrix, context)
|
|
70
|
+
findings = warning_findings(matrix)
|
|
71
|
+
if matrix.sampled?
|
|
72
|
+
findings << finding(
|
|
73
|
+
rule_id: "sampled_mutants",
|
|
74
|
+
severity: :info,
|
|
75
|
+
location: nil,
|
|
76
|
+
message: "Mutation analysis used a deterministic sample because generated mutants exceeded the configured limit.",
|
|
77
|
+
suggestion: "Increase mutation.max_mutants or run a narrower target for full precision."
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
if matrix.timeout_ratio > 0.2
|
|
81
|
+
findings << finding(
|
|
82
|
+
rule_id: "timeout_rate_high",
|
|
83
|
+
severity: :warning,
|
|
84
|
+
location: nil,
|
|
85
|
+
message: "More than 20% of counted mutants timed out.",
|
|
86
|
+
suggestion: "Increase mutation.timeout or inspect timeout-prone code before trusting the score."
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
threshold = context.config_fetch(:mutation, :equivalent_threshold, default: 0.2).to_f
|
|
90
|
+
if matrix.equivalent_ratio > threshold
|
|
91
|
+
findings << finding(
|
|
92
|
+
rule_id: "equivalent_rate_high",
|
|
93
|
+
severity: :warning,
|
|
94
|
+
location: nil,
|
|
95
|
+
message: "Equivalent mutant annotations exceed the configured threshold.",
|
|
96
|
+
suggestion: "Review equivalent annotations to avoid hiding real surviving mutants."
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
findings
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def warning_findings(matrix)
|
|
103
|
+
Array((matrix.metadata || {})["warnings"]).map do |warning|
|
|
104
|
+
type = warning.is_a?(Hash) ? warning.fetch("type", "mutation_warning") : "mutation_warning"
|
|
105
|
+
message = warning.is_a?(Hash) ? warning.fetch("message", warning.inspect) : warning.to_s
|
|
106
|
+
finding(
|
|
107
|
+
rule_id: type,
|
|
108
|
+
severity: :warning,
|
|
109
|
+
location: nil,
|
|
110
|
+
message: message,
|
|
111
|
+
suggestion: suggestion_for_warning(type)
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def suggestion_for_warning(type)
|
|
117
|
+
return "Fix or quarantine the failing baseline test before trusting mutation results." if type.to_s == "unstable_baseline"
|
|
118
|
+
|
|
119
|
+
"Review mutation execution settings."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def confidence_for(matrix)
|
|
123
|
+
return :medium if matrix.sampled? || matrix.timeout_ratio > 0.2
|
|
124
|
+
|
|
125
|
+
:high
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def mutant_message(mutant)
|
|
129
|
+
return "Mutant #{mutant.id} timed out." if mutant.status.to_s == "timeout"
|
|
130
|
+
|
|
131
|
+
"Mutant #{mutant.id} survived."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def mutant_suggestion(mutant)
|
|
135
|
+
return "Increase timeout or add a faster assertion that distinguishes this mutation." if mutant.status.to_s == "timeout"
|
|
136
|
+
|
|
137
|
+
mutant.killing_hint || "Add an expectation that distinguishes this mutation."
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../analyzer"
|
|
4
|
+
|
|
5
|
+
module SixthSense
|
|
6
|
+
module Analyzers
|
|
7
|
+
class QualityAssertionDensity < Analyzer
|
|
8
|
+
analyzer_id "quality/assertion_density"
|
|
9
|
+
axis :quality
|
|
10
|
+
level 0
|
|
11
|
+
|
|
12
|
+
reference authors: "Kudrjavets, G.; Nagappan, N.; Ball, T.",
|
|
13
|
+
title: "Assessing the Relationship between Software Assertions and Faults",
|
|
14
|
+
venue: "ISSRE",
|
|
15
|
+
year: 2006,
|
|
16
|
+
doi: "10.1109/ISSRE.2006.14"
|
|
17
|
+
|
|
18
|
+
# Paper basis: Kudrjavets/Nagappan/Ball 2006
|
|
19
|
+
# (doi:10.1109/ISSRE.2006.14) links assertion density with fault outcomes.
|
|
20
|
+
def analyze(test_file, context)
|
|
21
|
+
executable_loc = [test_file.executable_loc, 1].max
|
|
22
|
+
density = test_file.assertions.length.to_f / executable_loc
|
|
23
|
+
density_min = context.config_fetch(:quality, :assertion_density_min, default: 0.05)
|
|
24
|
+
return result(score: 100.0, findings: [], confidence: :high) if density >= density_min
|
|
25
|
+
|
|
26
|
+
penalty = (1.0 - (density / density_min)) * 10.0
|
|
27
|
+
findings = [
|
|
28
|
+
finding(
|
|
29
|
+
rule_id: "low_assertion_density",
|
|
30
|
+
severity: :warning,
|
|
31
|
+
location: test_file.test_cases.first&.location,
|
|
32
|
+
message: "Assertion density is #{density.round(3)} per executable test line.",
|
|
33
|
+
suggestion: "Add explicit expectations for the behavior this file exercises."
|
|
34
|
+
)
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
result(score: (100.0 - penalty).round(2), findings: findings, confidence: :high)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|