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,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SixthSense
|
|
4
|
+
module Result
|
|
5
|
+
Reference = Struct.new(:authors, :title, :venue, :year, :doi, keyword_init: true) do
|
|
6
|
+
def to_h
|
|
7
|
+
{
|
|
8
|
+
authors: authors,
|
|
9
|
+
title: title,
|
|
10
|
+
venue: venue,
|
|
11
|
+
year: year,
|
|
12
|
+
doi: doi
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Finding = Struct.new(
|
|
18
|
+
:analyzer_id,
|
|
19
|
+
:rule_id,
|
|
20
|
+
:severity,
|
|
21
|
+
:location,
|
|
22
|
+
:message,
|
|
23
|
+
:references,
|
|
24
|
+
:suggestion,
|
|
25
|
+
keyword_init: true
|
|
26
|
+
) do
|
|
27
|
+
def to_h
|
|
28
|
+
{
|
|
29
|
+
analyzer_id: analyzer_id,
|
|
30
|
+
rule_id: rule_id,
|
|
31
|
+
severity: severity,
|
|
32
|
+
location: location&.to_h,
|
|
33
|
+
message: message,
|
|
34
|
+
references: references.map(&:to_h),
|
|
35
|
+
suggestion: suggestion
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
AxisScore = Struct.new(
|
|
41
|
+
:axis,
|
|
42
|
+
:value,
|
|
43
|
+
:confidence,
|
|
44
|
+
:findings,
|
|
45
|
+
:measured,
|
|
46
|
+
keyword_init: true
|
|
47
|
+
) do
|
|
48
|
+
def measured?
|
|
49
|
+
measured != false && !value.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_h
|
|
53
|
+
{
|
|
54
|
+
axis: axis,
|
|
55
|
+
value: value,
|
|
56
|
+
confidence: confidence,
|
|
57
|
+
measured: measured?,
|
|
58
|
+
findings: findings.map(&:to_h)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
FileReport = Struct.new(
|
|
64
|
+
:test_file,
|
|
65
|
+
:axis_scores,
|
|
66
|
+
:composite,
|
|
67
|
+
:level,
|
|
68
|
+
:warnings,
|
|
69
|
+
keyword_init: true
|
|
70
|
+
) do
|
|
71
|
+
def findings
|
|
72
|
+
axis_scores.flat_map(&:findings)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{
|
|
77
|
+
test_file: test_file.to_h,
|
|
78
|
+
axis_scores: axis_scores.to_h { |score| [score.axis, score.to_h] },
|
|
79
|
+
composite: composite,
|
|
80
|
+
level: level,
|
|
81
|
+
warnings: warnings
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "../model"
|
|
6
|
+
|
|
7
|
+
module SixthSense
|
|
8
|
+
module Runners
|
|
9
|
+
class CheckedCoverageEstimator
|
|
10
|
+
def estimate(test_file, coverage_map)
|
|
11
|
+
checked = {}
|
|
12
|
+
executed = Hash.new { |hash, key| hash[key] = Set.new }
|
|
13
|
+
|
|
14
|
+
test_file.test_cases.each do |test_case|
|
|
15
|
+
covered_by_path = group_requirements(coverage_map.line_requirements_for(test_case.id))
|
|
16
|
+
sut_paths = test_file.sut_candidates.map { |unit| File.expand_path(unit.path) }.to_set
|
|
17
|
+
covered_by_path.each do |path, lines|
|
|
18
|
+
executed[path].merge(lines) if sut_paths.include?(File.expand_path(path))
|
|
19
|
+
end
|
|
20
|
+
checked[test_case.id] = checked_lines_for(test_file, test_case, covered_by_path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Model::CheckedCoverageMap.new(
|
|
24
|
+
per_test_checked_lines: checked,
|
|
25
|
+
executed_lines: executed.transform_values(&:to_a)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def checked_lines_for(test_file, test_case, covered_by_path)
|
|
32
|
+
tokens = assertion_tokens(test_case)
|
|
33
|
+
return {} if tokens.empty?
|
|
34
|
+
|
|
35
|
+
test_file.sut_candidates.each_with_object({}) do |unit, checked|
|
|
36
|
+
covered_path = covered_by_path.keys.find { |path| File.expand_path(path) == File.expand_path(unit.path) }
|
|
37
|
+
next unless covered_path
|
|
38
|
+
next unless token_matches_unit?(tokens, unit)
|
|
39
|
+
|
|
40
|
+
checked[covered_path] = covered_by_path.fetch(covered_path).to_a
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def group_requirements(requirements)
|
|
45
|
+
requirements.each_with_object(Hash.new { |hash, key| hash[key] = Set.new }) do |(path, line), grouped|
|
|
46
|
+
grouped[path] << line
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def assertion_tokens(test_case)
|
|
51
|
+
test_case.assertions.flat_map do |assertion|
|
|
52
|
+
assertion.subject_expr.to_s.scan(/[A-Za-z_]\w*[!?=]?/)
|
|
53
|
+
end.to_set
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def token_matches_unit?(tokens, unit)
|
|
57
|
+
unit.constants.any? { |constant| tokens.include?(constant.split("::").last) } ||
|
|
58
|
+
unit.methods.any? { |method_name| tokens.include?(method_name.delete_suffix("=")) || tokens.include?(method_name) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
|
|
8
|
+
require_relative "../framework_adapter"
|
|
9
|
+
require_relative "../model"
|
|
10
|
+
|
|
11
|
+
module SixthSense
|
|
12
|
+
module Runners
|
|
13
|
+
class CheckedCoverageRunner
|
|
14
|
+
def initialize(config: {})
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(test_files:)
|
|
19
|
+
test_files.to_h do |test_file|
|
|
20
|
+
[test_file.path, run_file(test_file)]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def run_file(test_file)
|
|
27
|
+
return fallback_empty unless checked_coverage_supported?(test_file)
|
|
28
|
+
|
|
29
|
+
per_test_checked = {}
|
|
30
|
+
executed = Hash.new { |hash, key| hash[key] = [] }
|
|
31
|
+
test_file.test_cases.each do |test_case|
|
|
32
|
+
next if test_case.metadata[:pending]
|
|
33
|
+
|
|
34
|
+
snapshot = process_snapshot(test_file, test_case)
|
|
35
|
+
per_test_checked[test_case.id] = snapshot.fetch("checked_lines", {})
|
|
36
|
+
snapshot.fetch("executed_lines", {}).each do |path, lines|
|
|
37
|
+
executed[path] |= lines
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Model::CheckedCoverageMap.new(
|
|
42
|
+
per_test_checked_lines: per_test_checked,
|
|
43
|
+
executed_lines: executed
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fallback_empty
|
|
48
|
+
Model::CheckedCoverageMap.new(per_test_checked_lines: {}, executed_lines: {})
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def process_snapshot(test_file, test_case)
|
|
52
|
+
Tempfile.create(["sixth_sense_checked", ".json"]) do |file|
|
|
53
|
+
file.close
|
|
54
|
+
env = {
|
|
55
|
+
"SIXTH_SENSE_CHECKED_OUTPUT" => file.path,
|
|
56
|
+
"SIXTH_SENSE_SUT_PATHS" => test_file.sut_candidates.map { |unit| File.expand_path(unit.path) }.join(File::PATH_SEPARATOR),
|
|
57
|
+
"SIXTH_SENSE_CHECKED_WINDOW" => checked_window.to_s
|
|
58
|
+
}
|
|
59
|
+
command = checked_coverage_command(test_file, test_case)
|
|
60
|
+
_stdout, stderr, status = Open3.capture3(env, *command)
|
|
61
|
+
raise Error, "checked coverage run failed for #{test_case.id}: #{stderr}" unless status.success?
|
|
62
|
+
|
|
63
|
+
JSON.parse(File.read(file.path))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def checked_coverage_command(test_file, test_case)
|
|
68
|
+
custom_command = adapter_for(test_file)&.checked_coverage_test_command(test_case: test_case, config: @config)
|
|
69
|
+
return custom_command if custom_command
|
|
70
|
+
|
|
71
|
+
case test_file.framework
|
|
72
|
+
when :rspec
|
|
73
|
+
[
|
|
74
|
+
RbConfig.ruby,
|
|
75
|
+
*load_paths.flat_map { |path| ["-I", path] },
|
|
76
|
+
"-rsixth_sense/runners/rspec_checked_coverage_probe",
|
|
77
|
+
"-e",
|
|
78
|
+
"SixthSense::Runners::RSpecCheckedCoverageProbe.run(ARGV[0], ARGV[1])",
|
|
79
|
+
test_case.location.path,
|
|
80
|
+
test_case.location.line.to_s
|
|
81
|
+
]
|
|
82
|
+
when :minitest
|
|
83
|
+
[
|
|
84
|
+
RbConfig.ruby,
|
|
85
|
+
*load_paths.flat_map { |path| ["-I", path] },
|
|
86
|
+
"-rsixth_sense/runners/minitest_checked_coverage_probe",
|
|
87
|
+
"-e",
|
|
88
|
+
"SixthSense::Runners::MinitestCheckedCoverageProbe.run(ARGV[0], ARGV[1])",
|
|
89
|
+
test_case.location.path,
|
|
90
|
+
test_case.id.split(":", 2).last
|
|
91
|
+
]
|
|
92
|
+
when :test_unit
|
|
93
|
+
[
|
|
94
|
+
RbConfig.ruby,
|
|
95
|
+
*load_paths.flat_map { |path| ["-I", path] },
|
|
96
|
+
"-rsixth_sense/runners/test_unit_checked_coverage_probe",
|
|
97
|
+
"-e",
|
|
98
|
+
"SixthSense::Runners::TestUnitCheckedCoverageProbe.run(ARGV[0], ARGV[1])",
|
|
99
|
+
test_case.location.path,
|
|
100
|
+
test_case.id.split(":", 2).last
|
|
101
|
+
]
|
|
102
|
+
else
|
|
103
|
+
raise NotImplementedError, "checked coverage is not implemented for #{test_file.framework}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def checked_coverage_supported?(test_file)
|
|
108
|
+
return true if %i[rspec minitest test_unit].include?(test_file.framework)
|
|
109
|
+
|
|
110
|
+
adapter = adapter_for(test_file)
|
|
111
|
+
adapter && adapter.method(:checked_coverage_test_command).owner != FrameworkAdapter
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def load_paths
|
|
115
|
+
configured = Array(@config.fetch("load_path", "lib"))
|
|
116
|
+
(configured + [File.expand_path("../..", __dir__)]).uniq
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def checked_window
|
|
120
|
+
return 0 if @config.fetch("checked_mode", "windowed").to_s == "strict"
|
|
121
|
+
|
|
122
|
+
[@config.fetch("checked_window", 20).to_i, 0].max
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def adapter_for(test_file)
|
|
126
|
+
FrameworkAdapter.for_path(test_file.path)&.new
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module SixthSense
|
|
7
|
+
module Runners
|
|
8
|
+
module CheckedCoverageTrace
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Paper basis: Schuler/Zeller 2011 (doi:10.1109/ICST.2011.32).
|
|
12
|
+
# This captures a TracePoint approximation of checked coverage rather
|
|
13
|
+
# than their full VM-level dynamic backward slicing.
|
|
14
|
+
def capture
|
|
15
|
+
executed = Hash.new { |hash, key| hash[key] = Set.new }
|
|
16
|
+
checked = Hash.new { |hash, key| hash[key] = Set.new }
|
|
17
|
+
trace = tracepoint(executed, checked)
|
|
18
|
+
trace.enable
|
|
19
|
+
yield
|
|
20
|
+
ensure
|
|
21
|
+
trace.disable if trace
|
|
22
|
+
write_snapshot(executed, checked) if executed && checked
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Paper basis: Schuler/Zeller 2011; assertion calls are treated as
|
|
26
|
+
# oracle boundaries, and SUT lines observed inside that boundary are checked.
|
|
27
|
+
def tracepoint(executed, checked)
|
|
28
|
+
TracePoint.new(:line, :call, :c_call, :return, :c_return) do |event|
|
|
29
|
+
case event.event
|
|
30
|
+
when :line
|
|
31
|
+
next unless sut_path?(event.path)
|
|
32
|
+
|
|
33
|
+
executed[event.path] << event.lineno
|
|
34
|
+
remember_line(event.path, event.lineno)
|
|
35
|
+
checked[event.path] << event.lineno if assertion_stack.positive?
|
|
36
|
+
when :call, :c_call
|
|
37
|
+
next unless assertion_method?(event.method_id)
|
|
38
|
+
|
|
39
|
+
mark_recent_lines(checked)
|
|
40
|
+
self.assertion_stack += 1
|
|
41
|
+
when :return, :c_return
|
|
42
|
+
next unless assertion_method?(event.method_id) && assertion_stack.positive?
|
|
43
|
+
|
|
44
|
+
self.assertion_stack -= 1
|
|
45
|
+
clear_recent_lines
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def assertion_method?(method_id)
|
|
51
|
+
name = method_id.to_s
|
|
52
|
+
return true if %w[expect should should_not flunk].include?(name)
|
|
53
|
+
return true if name.start_with?("assert", "refute", "must_", "wont_")
|
|
54
|
+
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sut_path?(path)
|
|
59
|
+
sut_paths.include?(File.expand_path(path))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sut_paths
|
|
63
|
+
@sut_paths ||= ENV.fetch("SIXTH_SENSE_SUT_PATHS", "").split(File::PATH_SEPARATOR).map { |path| File.expand_path(path) }.to_set
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def assertion_stack
|
|
67
|
+
Thread.current[:sixth_sense_assertion_stack].to_i
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def assertion_stack=(value)
|
|
71
|
+
Thread.current[:sixth_sense_assertion_stack] = value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def remember_line(path, line)
|
|
75
|
+
recent_lines << [path, line]
|
|
76
|
+
recent_lines.shift while recent_lines.length > recent_window
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def mark_recent_lines(checked)
|
|
80
|
+
recent_lines.each do |path, line|
|
|
81
|
+
checked[path] << line
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def clear_recent_lines
|
|
86
|
+
Thread.current[:sixth_sense_recent_sut_lines] = []
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def recent_lines
|
|
90
|
+
Thread.current[:sixth_sense_recent_sut_lines] ||= []
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def recent_window
|
|
94
|
+
ENV.fetch("SIXTH_SENSE_CHECKED_WINDOW", "20").to_i
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def write_snapshot(executed, checked)
|
|
98
|
+
File.write(
|
|
99
|
+
ENV.fetch("SIXTH_SENSE_CHECKED_OUTPUT"),
|
|
100
|
+
JSON.generate(
|
|
101
|
+
{
|
|
102
|
+
executed_lines: executed.transform_values(&:to_a),
|
|
103
|
+
checked_lines: checked.transform_values(&:to_a)
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "set"
|
|
8
|
+
require "stringio"
|
|
9
|
+
require "tempfile"
|
|
10
|
+
|
|
11
|
+
require_relative "../framework_adapter"
|
|
12
|
+
require_relative "../mutation_engine"
|
|
13
|
+
require_relative "../model"
|
|
14
|
+
require_relative "coverage_snapshot"
|
|
15
|
+
|
|
16
|
+
module SixthSense
|
|
17
|
+
module Runners
|
|
18
|
+
class CoverageRunner
|
|
19
|
+
def initialize(config: {})
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def availability
|
|
24
|
+
return Availability.new(available: false, reason: "Ruby Coverage module is unavailable") unless defined?(Coverage)
|
|
25
|
+
return Availability.new(available: false, reason: "Coverage.start is unavailable") unless Coverage.respond_to?(:start)
|
|
26
|
+
|
|
27
|
+
Availability.new(available: true, reason: "Ruby Coverage module is available")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run(test_files:, isolation: :process)
|
|
31
|
+
available = availability
|
|
32
|
+
unless available.available?
|
|
33
|
+
raise Error, "coverage runner unavailable: #{available.reason}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
test_files.to_h do |test_file|
|
|
37
|
+
[test_file.path, run_file(test_file, isolation: isolation)]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def run_file(test_file, isolation:)
|
|
44
|
+
per_test_lines = {}
|
|
45
|
+
per_test_branches = {}
|
|
46
|
+
all_lines = Hash.new { |hash, key| hash[key] = Set.new }
|
|
47
|
+
all_branches = Hash.new { |hash, key| hash[key] = Set.new }
|
|
48
|
+
|
|
49
|
+
test_file.test_cases.each do |test_case|
|
|
50
|
+
next if test_case.metadata[:pending]
|
|
51
|
+
|
|
52
|
+
snapshot = isolation == :none ? in_process_snapshot(test_file, test_case) : process_snapshot(test_file, test_case)
|
|
53
|
+
snapshot = filter_snapshot(snapshot, test_file)
|
|
54
|
+
per_test_lines[test_case.id] = snapshot.fetch("lines", {})
|
|
55
|
+
per_test_branches[test_case.id] = snapshot.fetch("branches", {})
|
|
56
|
+
merge_requirements(all_lines, per_test_lines[test_case.id])
|
|
57
|
+
merge_requirements(all_branches, per_test_branches[test_case.id])
|
|
58
|
+
merge_requirements(all_lines, snapshot.fetch("executable_lines", {}))
|
|
59
|
+
merge_requirements(all_branches, snapshot.fetch("executable_branches", {}))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Model::CoverageMap.new(
|
|
63
|
+
per_test_lines: deep_sets(per_test_lines),
|
|
64
|
+
per_test_branches: deep_sets(per_test_branches),
|
|
65
|
+
total_lines: all_lines.transform_values(&:to_a),
|
|
66
|
+
total_branches: all_branches.transform_values(&:to_a)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def empty_coverage_map
|
|
71
|
+
Model::CoverageMap.new(
|
|
72
|
+
per_test_lines: {},
|
|
73
|
+
per_test_branches: {},
|
|
74
|
+
total_lines: {},
|
|
75
|
+
total_branches: {}
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def process_snapshot(test_file, test_case)
|
|
80
|
+
Tempfile.create(["sixth_sense_coverage", ".json"]) do |file|
|
|
81
|
+
file.close
|
|
82
|
+
command = coverage_command(test_file, test_case)
|
|
83
|
+
env = { "SIXTH_SENSE_COVERAGE_OUTPUT" => file.path }
|
|
84
|
+
_stdout, stderr, status = Open3.capture3(env, *command)
|
|
85
|
+
raise Error, "coverage run failed for #{test_case.id}: #{stderr}" unless status.success?
|
|
86
|
+
|
|
87
|
+
JSON.parse(File.read(file.path))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def coverage_command(test_file, test_case)
|
|
92
|
+
custom_command = adapter_for(test_file)&.coverage_test_command(test_case: test_case, config: @config)
|
|
93
|
+
return custom_command if custom_command
|
|
94
|
+
|
|
95
|
+
case test_file.framework
|
|
96
|
+
when :rspec
|
|
97
|
+
[
|
|
98
|
+
RbConfig.ruby,
|
|
99
|
+
*load_paths.flat_map { |path| ["-I", path] },
|
|
100
|
+
"-rsixth_sense/runners/rspec_coverage_probe",
|
|
101
|
+
"-e",
|
|
102
|
+
"SixthSense::Runners::RSpecCoverageProbe.run(ARGV[0], ARGV[1])",
|
|
103
|
+
test_case.location.path,
|
|
104
|
+
test_case.location.line.to_s
|
|
105
|
+
]
|
|
106
|
+
when :minitest
|
|
107
|
+
[
|
|
108
|
+
RbConfig.ruby,
|
|
109
|
+
*load_paths.flat_map { |path| ["-I", path] },
|
|
110
|
+
"-rsixth_sense/runners/minitest_coverage_probe",
|
|
111
|
+
"-e",
|
|
112
|
+
"SixthSense::Runners::MinitestCoverageProbe.run(ARGV[0], ARGV[1])",
|
|
113
|
+
test_case.location.path,
|
|
114
|
+
test_case.id.split(":", 2).last
|
|
115
|
+
]
|
|
116
|
+
when :test_unit
|
|
117
|
+
[
|
|
118
|
+
RbConfig.ruby,
|
|
119
|
+
*load_paths.flat_map { |path| ["-I", path] },
|
|
120
|
+
"-rsixth_sense/runners/test_unit_coverage_probe",
|
|
121
|
+
"-e",
|
|
122
|
+
"SixthSense::Runners::TestUnitCoverageProbe.run(ARGV[0], ARGV[1])",
|
|
123
|
+
test_case.location.path,
|
|
124
|
+
test_case.id.split(":", 2).last
|
|
125
|
+
]
|
|
126
|
+
else
|
|
127
|
+
raise NotImplementedError, "coverage is not implemented for #{test_file.framework}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def in_process_snapshot(test_file, test_case)
|
|
132
|
+
load_paths.each { |path| $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path) }
|
|
133
|
+
Coverage.start(lines: true, branches: true)
|
|
134
|
+
passed = run_in_process(test_file, test_case)
|
|
135
|
+
result = Coverage.result
|
|
136
|
+
raise Error, "in-process coverage run failed for #{test_case.id}" unless passed
|
|
137
|
+
|
|
138
|
+
CoverageSnapshot.snapshot_for(result)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def run_in_process(test_file, test_case)
|
|
142
|
+
case test_file.framework
|
|
143
|
+
when :rspec then run_rspec_in_process(test_case)
|
|
144
|
+
when :minitest then run_minitest_in_process(test_case)
|
|
145
|
+
when :test_unit then run_test_unit_in_process(test_case)
|
|
146
|
+
else
|
|
147
|
+
raise NotImplementedError, "coverage is not implemented for #{test_file.framework}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def run_rspec_in_process(test_case)
|
|
152
|
+
require "rspec/core"
|
|
153
|
+
require "rspec/expectations"
|
|
154
|
+
|
|
155
|
+
RSpec::Core::Runner.run(["--options", File::NULL, "#{test_case.location.path}:#{test_case.location.line}"], StringIO.new, StringIO.new).zero?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def run_minitest_in_process(test_case)
|
|
159
|
+
require File.expand_path(test_case.location.path)
|
|
160
|
+
Minitest.run(["--name", "/\\A#{Regexp.escape(test_case.id.split(":", 2).last)}\\z/"])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def run_test_unit_in_process(test_case)
|
|
164
|
+
require "test/unit"
|
|
165
|
+
require "test/unit/testresult"
|
|
166
|
+
require "test/unit/worker-context"
|
|
167
|
+
Test::Unit::AutoRunner.need_auto_run = false
|
|
168
|
+
require File.expand_path(test_case.location.path)
|
|
169
|
+
klass = test_unit_class_for(test_case.id.split(":", 2).last)
|
|
170
|
+
return false unless klass
|
|
171
|
+
|
|
172
|
+
result = Test::Unit::TestResult.new
|
|
173
|
+
test = klass.new(test_case.id.split(":", 2).last)
|
|
174
|
+
test.instance_variable_set(:@passed_assertions, [])
|
|
175
|
+
test.run(result) { |_event, *_args| }
|
|
176
|
+
result.passed?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def test_unit_classes
|
|
180
|
+
ObjectSpace.each_object(Class).select { |klass| klass < Test::Unit::TestCase }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def test_unit_class_for(test_name)
|
|
184
|
+
test_unit_classes.find { |candidate| candidate.public_instance_methods.map(&:to_s).include?(test_name) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def filter_snapshot(snapshot, test_file)
|
|
188
|
+
allowed = test_file.sut_candidates.map { |unit| File.expand_path(unit.path) }.to_set
|
|
189
|
+
return snapshot if allowed.empty?
|
|
190
|
+
|
|
191
|
+
snapshot.transform_values do |requirements|
|
|
192
|
+
next requirements unless requirements.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
requirements.select { |path, _items| allowed.include?(File.expand_path(path)) }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def load_paths
|
|
199
|
+
configured = Array(@config.fetch("load_path", "lib"))
|
|
200
|
+
(configured + [File.expand_path("../..", __dir__)]).uniq
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def adapter_for(test_file)
|
|
204
|
+
FrameworkAdapter.for_path(test_file.path)&.new
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def merge_requirements(target, requirements)
|
|
208
|
+
requirements.each do |path, items|
|
|
209
|
+
target[path].merge(items)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def deep_sets(hash)
|
|
214
|
+
hash.transform_values do |requirements|
|
|
215
|
+
requirements.transform_values { |items| items.to_set }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SixthSense
|
|
4
|
+
module Runners
|
|
5
|
+
module CoverageSnapshot
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def snapshot_for(result)
|
|
9
|
+
lines = {}
|
|
10
|
+
executable_lines = {}
|
|
11
|
+
branches = {}
|
|
12
|
+
executable_branches = {}
|
|
13
|
+
result.each do |path, payload|
|
|
14
|
+
line_counts = payload.is_a?(Hash) ? payload[:lines] || payload["lines"] : payload
|
|
15
|
+
branch_counts = payload[:branches] || payload["branches"] if payload.is_a?(Hash)
|
|
16
|
+
lines[path] = covered_lines(line_counts)
|
|
17
|
+
executable_lines[path] = executable_lines(line_counts)
|
|
18
|
+
branches[path] = covered_branches(branch_counts)
|
|
19
|
+
executable_branches[path] = executable_branches(branch_counts)
|
|
20
|
+
end
|
|
21
|
+
{
|
|
22
|
+
"lines" => lines,
|
|
23
|
+
"executable_lines" => executable_lines,
|
|
24
|
+
"branches" => branches,
|
|
25
|
+
"executable_branches" => executable_branches
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def covered_lines(line_counts)
|
|
30
|
+
Array(line_counts).each_with_index.filter_map { |count, index| index + 1 if count.to_i.positive? }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def executable_lines(line_counts)
|
|
34
|
+
Array(line_counts).each_with_index.filter_map { |count, index| index + 1 unless count.nil? }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def covered_branches(branches)
|
|
38
|
+
return [] unless branches.respond_to?(:each)
|
|
39
|
+
|
|
40
|
+
branches.each_with_index.filter_map do |branch, index|
|
|
41
|
+
"#{branch_key(branch)}:#{index}" if branch_executed?(branch)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def executable_branches(branches)
|
|
46
|
+
return [] unless branches.respond_to?(:each)
|
|
47
|
+
|
|
48
|
+
branches.each_with_index.map { |branch, index| "#{branch_key(branch)}:#{index}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def branch_key(branch)
|
|
52
|
+
branch.respond_to?(:first) ? branch.first.inspect : branch.inspect
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def branch_executed?(branch)
|
|
56
|
+
values = branch.respond_to?(:last) ? branch.last : branch
|
|
57
|
+
return values.to_i.positive? if values.is_a?(Integer)
|
|
58
|
+
return values.values.any? { |count| count.to_i.positive? } if values.respond_to?(:values)
|
|
59
|
+
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|