rspec-covers 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 +202 -0
- data/data/evaluation/ground_truth.example.json +26 -0
- data/lib/minitest/covers.rb +133 -0
- data/lib/rspec/covers/checked_coverage.rb +27 -0
- data/lib/rspec/covers/code_location.rb +49 -0
- data/lib/rspec/covers/configuration.rb +135 -0
- data/lib/rspec/covers/declaration.rb +199 -0
- data/lib/rspec/covers/declaration_validation.rb +65 -0
- data/lib/rspec/covers/evaluation.rb +197 -0
- data/lib/rspec/covers/formatter.rb +70 -0
- data/lib/rspec/covers/integration.rb +54 -0
- data/lib/rspec/covers/metadata_reader.rb +34 -0
- data/lib/rspec/covers/method_entry.rb +19 -0
- data/lib/rspec/covers/method_label.rb +37 -0
- data/lib/rspec/covers/probe/call_log_probe.rb +132 -0
- data/lib/rspec/covers/probe/coverage_probe.rb +57 -0
- data/lib/rspec/covers/production_inventory.rb +90 -0
- data/lib/rspec/covers/rake_task.rb +63 -0
- data/lib/rspec/covers/reporter.rb +162 -0
- data/lib/rspec/covers/source_range.rb +37 -0
- data/lib/rspec/covers/static_method_scanner.rb +184 -0
- data/lib/rspec/covers/strict_verdict.rb +107 -0
- data/lib/rspec/covers/tracer/composite.rb +73 -0
- data/lib/rspec/covers/tracer/dynamic.rb +27 -0
- data/lib/rspec/covers/tracer/dynamic_corpus.rb +43 -0
- data/lib/rspec/covers/tracer/lcba.rb +17 -0
- data/lib/rspec/covers/tracer/nc.rb +36 -0
- data/lib/rspec/covers/tracer/ncc.rb +23 -0
- data/lib/rspec/covers/tracer/suggestion.rb +17 -0
- data/lib/rspec/covers/tracer/tokenizer.rb +18 -0
- data/lib/rspec/covers/version.rb +11 -0
- data/lib/rspec/covers.rb +225 -0
- data/sig/minitest/covers.rbs +6 -0
- data/sig/rspec/covers.rbs +27 -0
- metadata +134 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "code_location"
|
|
4
|
+
require_relative "metadata_reader"
|
|
5
|
+
require_relative "method_label"
|
|
6
|
+
require_relative "production_inventory"
|
|
7
|
+
require_relative "source_range"
|
|
8
|
+
|
|
9
|
+
module RSpec
|
|
10
|
+
module Covers
|
|
11
|
+
class Declaration
|
|
12
|
+
attr_reader :covers, :uses, :unresolved
|
|
13
|
+
|
|
14
|
+
def initialize(covers:, uses:, unresolved: [])
|
|
15
|
+
@covers = covers
|
|
16
|
+
@uses = uses
|
|
17
|
+
@unresolved = unresolved
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_example(example, config)
|
|
21
|
+
resolver = Resolver.new(config)
|
|
22
|
+
covers = MetadataReader.declarations_for(example, :covers)
|
|
23
|
+
uses = MetadataReader.declarations_for(example, :uses)
|
|
24
|
+
|
|
25
|
+
new(
|
|
26
|
+
covers: covers.flat_map { |item| resolver.resolve(item) },
|
|
27
|
+
uses: uses.flat_map { |item| resolver.resolve(item) },
|
|
28
|
+
unresolved: resolver.unresolved
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def declared?
|
|
33
|
+
covers.any? || uses.any?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def regions
|
|
37
|
+
covers + uses
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def locations
|
|
41
|
+
regions.each_with_object(Set.new) do |region, set|
|
|
42
|
+
set.merge(region.locations)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Resolver
|
|
47
|
+
METHOD_PATTERN = /\A(.+)(#|\.)([^#.]+)\z/
|
|
48
|
+
GLOB_CHARS = /[*?\[\]{}]/
|
|
49
|
+
|
|
50
|
+
attr_reader :unresolved
|
|
51
|
+
|
|
52
|
+
def initialize(config)
|
|
53
|
+
@config = config
|
|
54
|
+
@unresolved = []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve(value)
|
|
58
|
+
case value
|
|
59
|
+
when CodeRegion
|
|
60
|
+
[value]
|
|
61
|
+
when Module
|
|
62
|
+
module_regions(value)
|
|
63
|
+
when Regexp
|
|
64
|
+
regexp_regions(value)
|
|
65
|
+
when Symbol
|
|
66
|
+
resolve(value.to_s)
|
|
67
|
+
when String
|
|
68
|
+
string_regions(value)
|
|
69
|
+
else
|
|
70
|
+
unresolved << value
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
attr_reader :config
|
|
78
|
+
|
|
79
|
+
def string_regions(value)
|
|
80
|
+
return glob_regions(value) if glob?(value)
|
|
81
|
+
|
|
82
|
+
if (match = value.match(METHOD_PATTERN))
|
|
83
|
+
return method_region(match[1], match[2], match[3], value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
constant = constantize(value)
|
|
87
|
+
return module_regions(constant) if constant.is_a?(Module)
|
|
88
|
+
|
|
89
|
+
return [CodeRegion.file(expand_path(value), label: value)] if File.file?(expand_path(value))
|
|
90
|
+
|
|
91
|
+
unresolved << value
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def glob_regions(pattern)
|
|
96
|
+
files = Dir.glob(expand_path(pattern)).select { |path| File.file?(path) }
|
|
97
|
+
unresolved << pattern if files.empty?
|
|
98
|
+
|
|
99
|
+
files.map { |file| CodeRegion.file(file, label: pattern) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def regexp_regions(pattern)
|
|
103
|
+
ProductionInventory.new(config).methods.filter_map do |entry|
|
|
104
|
+
next unless entry.label.match?(pattern) || config.relative_path(entry.file).match?(pattern)
|
|
105
|
+
|
|
106
|
+
entry.region
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def module_regions(mod)
|
|
111
|
+
method_regions = own_instance_methods(mod).filter_map do |method_name|
|
|
112
|
+
region_for_callable(mod.instance_method(method_name), MethodLabel.instance(mod, method_name))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
singleton_regions = mod.singleton_methods(false).filter_map do |method_name|
|
|
116
|
+
region_for_callable(mod.method(method_name), MethodLabel.singleton(mod, method_name))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
regions = method_regions + singleton_regions
|
|
120
|
+
return regions if regions.any?
|
|
121
|
+
|
|
122
|
+
constant_region(mod)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def method_region(class_name, separator, method_name, label)
|
|
126
|
+
constant = constantize(class_name)
|
|
127
|
+
unless constant.is_a?(Module)
|
|
128
|
+
unresolved << label
|
|
129
|
+
return []
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
callable = if separator == "#"
|
|
133
|
+
constant.instance_method(method_name)
|
|
134
|
+
else
|
|
135
|
+
constant.method(method_name)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
region = region_for_callable(callable, label)
|
|
139
|
+
region ? [region] : []
|
|
140
|
+
rescue NameError
|
|
141
|
+
unresolved << label
|
|
142
|
+
[]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def constant_region(mod)
|
|
146
|
+
location = const_source_location(mod)
|
|
147
|
+
return [] unless location
|
|
148
|
+
|
|
149
|
+
[CodeRegion.new(file: location[0], lines: [location[1]], label: mod.name)]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def loaded_method_regions
|
|
153
|
+
ObjectSpace.each_object(Module).flat_map { |mod| module_regions(mod) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def own_instance_methods(mod)
|
|
157
|
+
mod.instance_methods(false) +
|
|
158
|
+
mod.private_instance_methods(false) +
|
|
159
|
+
mod.protected_instance_methods(false)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def region_for_callable(callable, label)
|
|
163
|
+
source = callable.source_location
|
|
164
|
+
return nil unless source
|
|
165
|
+
|
|
166
|
+
file, start_line = source
|
|
167
|
+
last_line = SourceRange.last_line(callable) || start_line
|
|
168
|
+
CodeRegion.new(file: file, lines: start_line..last_line, label: label)
|
|
169
|
+
rescue ArgumentError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def const_source_location(mod)
|
|
174
|
+
return unless mod.name
|
|
175
|
+
|
|
176
|
+
Object.const_source_location(mod.name)
|
|
177
|
+
rescue NameError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def glob?(value)
|
|
182
|
+
value.match?(GLOB_CHARS) || value.include?("/") || value.end_with?(".rb")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def expand_path(path)
|
|
186
|
+
File.expand_path(path, config.root)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def constantize(name)
|
|
190
|
+
name.split("::").reject(&:empty?).reduce(Object) do |scope, const_name|
|
|
191
|
+
scope.const_get(const_name, false)
|
|
192
|
+
end
|
|
193
|
+
rescue NameError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Covers
|
|
7
|
+
DeclarationValidation = Struct.new(
|
|
8
|
+
:valid,
|
|
9
|
+
:declared_labels,
|
|
10
|
+
:supported_labels,
|
|
11
|
+
:unsupported_labels,
|
|
12
|
+
:suggestions,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
) do
|
|
15
|
+
def valid?
|
|
16
|
+
valid
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
{
|
|
21
|
+
valid: valid?,
|
|
22
|
+
declared_labels: declared_labels,
|
|
23
|
+
supported_labels: supported_labels,
|
|
24
|
+
unsupported_labels: unsupported_labels,
|
|
25
|
+
suggestions: suggestions.map(&:to_h)
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class DeclarationValidator
|
|
31
|
+
def initialize(config)
|
|
32
|
+
@config = config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(declaration:, suggestions:)
|
|
36
|
+
declared_labels = declaration.covers.map(&:label).uniq
|
|
37
|
+
return empty_validation if declared_labels.empty?
|
|
38
|
+
|
|
39
|
+
scores = suggestions.to_h { |suggestion| [suggestion.label, suggestion.score] }
|
|
40
|
+
supported = declared_labels.select { |label| scores.fetch(label, 0.0) >= @config.validation_threshold }
|
|
41
|
+
unsupported = declared_labels - supported
|
|
42
|
+
|
|
43
|
+
DeclarationValidation.new(
|
|
44
|
+
valid: unsupported.empty?,
|
|
45
|
+
declared_labels: declared_labels,
|
|
46
|
+
supported_labels: supported,
|
|
47
|
+
unsupported_labels: unsupported,
|
|
48
|
+
suggestions: suggestions
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def empty_validation
|
|
55
|
+
DeclarationValidation.new(
|
|
56
|
+
valid: true,
|
|
57
|
+
declared_labels: [],
|
|
58
|
+
supported_labels: [],
|
|
59
|
+
unsupported_labels: [],
|
|
60
|
+
suggestions: []
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Covers
|
|
8
|
+
class Evaluation
|
|
9
|
+
def self.evaluate(ground_truth_path:, predictions_path:)
|
|
10
|
+
new(
|
|
11
|
+
ground_truth: JSON.parse(File.read(ground_truth_path)),
|
|
12
|
+
predictions: JSON.parse(File.read(predictions_path))
|
|
13
|
+
).evaluate
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(ground_truth:, predictions:)
|
|
17
|
+
@targets = ground_truth.fetch("targets", {})
|
|
18
|
+
@ground_truth = normalize_ground_truth(ground_truth)
|
|
19
|
+
@predictions = normalize_predictions(predictions)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def evaluate
|
|
23
|
+
result = {
|
|
24
|
+
traceability: traceability_metrics,
|
|
25
|
+
examples: @ground_truth.length
|
|
26
|
+
}
|
|
27
|
+
strict = strict_metrics
|
|
28
|
+
seeded = seeded_strict_metrics
|
|
29
|
+
checked = checked_metrics
|
|
30
|
+
result[:strict] = strict if strict
|
|
31
|
+
result[:seeded_strict] = seeded if seeded
|
|
32
|
+
result[:checked] = checked if checked
|
|
33
|
+
result[:targets] = target_metrics(result) if @targets.any?
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def traceability_metrics
|
|
40
|
+
totals = @ground_truth.each_with_object({ tp: 0, predicted: 0, actual: 0, applicable: 0 }) do |(id, labels), memo|
|
|
41
|
+
predicted = @predictions.fetch(id, Set.new)
|
|
42
|
+
memo[:tp] += (labels & predicted).length
|
|
43
|
+
memo[:predicted] += predicted.length
|
|
44
|
+
memo[:actual] += labels.length
|
|
45
|
+
memo[:applicable] += 1 if predicted.any?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
precision = ratio(totals[:tp], totals[:predicted])
|
|
49
|
+
recall = ratio(totals[:tp], totals[:actual])
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
precision: precision,
|
|
53
|
+
recall: recall,
|
|
54
|
+
f1: precision + recall > 0 ? (2 * precision * recall / (precision + recall)) : 0.0,
|
|
55
|
+
applicability: ratio(totals[:applicable], @ground_truth.length)
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_ground_truth(raw)
|
|
60
|
+
@ground_truth_raw = raw.fetch("examples", raw)
|
|
61
|
+
|
|
62
|
+
if @ground_truth_raw.is_a?(Array)
|
|
63
|
+
@ground_truth_raw.each_with_object({}) do |example, normalized|
|
|
64
|
+
normalized[example.fetch("id")] = Set.new(Array(example["covers"] || example["labels"]))
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
@ground_truth_raw.each_with_object({}) do |(id, labels), normalized|
|
|
68
|
+
normalized[id] = Set.new(Array(labels))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_predictions(raw)
|
|
74
|
+
@prediction_examples = raw.fetch("examples", [])
|
|
75
|
+
|
|
76
|
+
@prediction_examples.each_with_object({}) do |example, normalized|
|
|
77
|
+
id = example.fetch("id")
|
|
78
|
+
labels = example.fetch("suggestions", []).map { |suggestion| suggestion.fetch("label") }
|
|
79
|
+
normalized[id] = Set.new(labels)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def strict_metrics
|
|
84
|
+
pairs = comparable_examples("risky") do |truth, prediction|
|
|
85
|
+
[truth.fetch("risky"), prediction.fetch("verdict").fetch("risky")]
|
|
86
|
+
end
|
|
87
|
+
return if pairs.empty?
|
|
88
|
+
|
|
89
|
+
binary_metrics(pairs)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def seeded_strict_metrics
|
|
93
|
+
pairs = comparable_examples("risky") do |truth, prediction|
|
|
94
|
+
next unless truth["seeded"]
|
|
95
|
+
|
|
96
|
+
[truth.fetch("risky"), prediction.fetch("verdict").fetch("risky")]
|
|
97
|
+
end
|
|
98
|
+
return if pairs.empty?
|
|
99
|
+
|
|
100
|
+
binary_metrics(pairs)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def checked_metrics
|
|
104
|
+
pairs = comparable_examples("checked_locations") do |truth, prediction|
|
|
105
|
+
[
|
|
106
|
+
Set.new(Array(truth.fetch("checked_locations")).map { |location| location_key(location) }),
|
|
107
|
+
Set.new(Array(prediction.fetch("measured_locations", [])).map { |location| location_key(location) })
|
|
108
|
+
]
|
|
109
|
+
end
|
|
110
|
+
return if pairs.empty?
|
|
111
|
+
|
|
112
|
+
totals = pairs.each_with_object({ tp: 0, predicted: 0, actual: 0 }) do |(actual, predicted), memo|
|
|
113
|
+
memo[:tp] += (actual & predicted).length
|
|
114
|
+
memo[:predicted] += predicted.length
|
|
115
|
+
memo[:actual] += actual.length
|
|
116
|
+
end
|
|
117
|
+
precision = ratio(totals[:tp], totals[:predicted])
|
|
118
|
+
recall = ratio(totals[:tp], totals[:actual])
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
precision: precision,
|
|
122
|
+
recall: recall,
|
|
123
|
+
f1: precision + recall > 0 ? (2 * precision * recall / (precision + recall)) : 0.0,
|
|
124
|
+
examples: pairs.length
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def comparable_examples(key)
|
|
129
|
+
return [] unless @ground_truth_raw.is_a?(Array)
|
|
130
|
+
|
|
131
|
+
predictions_by_id = @prediction_examples.to_h { |example| [example.fetch("id"), example] }
|
|
132
|
+
|
|
133
|
+
@ground_truth_raw.filter_map do |truth|
|
|
134
|
+
next unless truth.key?(key)
|
|
135
|
+
|
|
136
|
+
prediction = predictions_by_id[truth.fetch("id")]
|
|
137
|
+
next unless prediction
|
|
138
|
+
|
|
139
|
+
yield truth, prediction
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def binary_metrics(pairs)
|
|
144
|
+
totals = pairs.each_with_object({ tp: 0, fp: 0, fn: 0, tn: 0 }) do |(actual, predicted), memo|
|
|
145
|
+
case [actual, predicted]
|
|
146
|
+
when [true, true]
|
|
147
|
+
memo[:tp] += 1
|
|
148
|
+
when [false, true]
|
|
149
|
+
memo[:fp] += 1
|
|
150
|
+
when [true, false]
|
|
151
|
+
memo[:fn] += 1
|
|
152
|
+
when [false, false]
|
|
153
|
+
memo[:tn] += 1
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
precision = ratio(totals[:tp], totals[:tp] + totals[:fp])
|
|
158
|
+
recall = ratio(totals[:tp], totals[:tp] + totals[:fn])
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
precision: precision,
|
|
162
|
+
recall: recall,
|
|
163
|
+
false_positive_rate: ratio(totals[:fp], totals[:fp] + totals[:tn]),
|
|
164
|
+
examples: pairs.length
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def location_key(location)
|
|
169
|
+
return location if location.is_a?(String)
|
|
170
|
+
|
|
171
|
+
"#{location.fetch("file")}:#{location.fetch("line")}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def target_metrics(result)
|
|
175
|
+
@targets.each_with_object({}) do |(key, target), metrics|
|
|
176
|
+
value = metric_value(result, key)
|
|
177
|
+
metrics[key] = {
|
|
178
|
+
target: target,
|
|
179
|
+
actual: value,
|
|
180
|
+
passed: !value.nil? && value >= target.to_f
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def metric_value(result, key)
|
|
186
|
+
section, metric = key.split(".", 2)
|
|
187
|
+
return unless section && metric
|
|
188
|
+
|
|
189
|
+
result.dig(section.to_sym, metric.to_sym)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def ratio(numerator, denominator)
|
|
193
|
+
denominator.positive? ? numerator.to_f / denominator : 0.0
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Covers
|
|
7
|
+
class Formatter
|
|
8
|
+
::RSpec::Core::Formatters.register self, :example_finished, :dump_summary if defined?(::RSpec::Core::Formatters)
|
|
9
|
+
|
|
10
|
+
def initialize(output)
|
|
11
|
+
@output = output
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def dump_summary(_notification)
|
|
15
|
+
reporter = RSpec::Covers.reporter
|
|
16
|
+
return if reporter.results.empty?
|
|
17
|
+
|
|
18
|
+
@output.puts "\nrspec-covers:"
|
|
19
|
+
@output.puts " risky examples: #{reporter.risky_results.length}"
|
|
20
|
+
@output.puts " declaration warnings: #{reporter.validation_warnings.length}"
|
|
21
|
+
@output.puts " suggestions: #{reporter.suggestions.length}"
|
|
22
|
+
|
|
23
|
+
reporter.risky_results.each do |result|
|
|
24
|
+
@output.puts " risky: #{result.file}:#{result.line} #{result.description}"
|
|
25
|
+
result.verdict.grouped_evidence(limit: 5).each do |evidence|
|
|
26
|
+
@output.puts " #{evidence[:file]}:#{evidence[:lines].join(",")}"
|
|
27
|
+
@output.puts " methods: #{evidence[:methods].join(", ")}" if evidence[:methods].any?
|
|
28
|
+
@output.puts " note: #{evidence[:note]}" if evidence[:note]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
reporter.validation_warnings.each do |result|
|
|
33
|
+
validation = result.declaration_validation
|
|
34
|
+
@output.puts " declaration warning: #{result.file}:#{result.line} #{result.description}"
|
|
35
|
+
@output.puts " weak: #{validation.unsupported_labels.join(", ")}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
reporter.results.each do |result|
|
|
39
|
+
next if Array(result.unchecked_locations).empty?
|
|
40
|
+
|
|
41
|
+
@output.puts colorize(" unchecked: #{result.file}:#{result.line} #{result.description}", 33)
|
|
42
|
+
result.unchecked_locations.group_by(&:file).each do |file, locations|
|
|
43
|
+
@output.puts colorize(" #{file}:#{locations.map(&:line).sort.join(",")}", 33)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def example_finished(notification)
|
|
49
|
+
return unless RSpec::Covers.configuration.json_events
|
|
50
|
+
|
|
51
|
+
result = RSpec::Covers.reporter.results.find { |item| item.id == notification.example.id }
|
|
52
|
+
return unless result
|
|
53
|
+
|
|
54
|
+
@output.puts JSON.generate(type: "rspec_covers.example", example: result.to_h)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def colorize(text, code)
|
|
60
|
+
return text unless color_enabled?
|
|
61
|
+
|
|
62
|
+
"\e[#{code}m#{text}\e[0m"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def color_enabled?
|
|
66
|
+
defined?(::RSpec) && ::RSpec.respond_to?(:configuration) && ::RSpec.configuration.color_enabled?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "checked_coverage"
|
|
4
|
+
require_relative "declaration"
|
|
5
|
+
require_relative "probe/call_log_probe"
|
|
6
|
+
require_relative "probe/coverage_probe"
|
|
7
|
+
require_relative "reporter"
|
|
8
|
+
require_relative "strict_verdict"
|
|
9
|
+
require_relative "tracer/composite"
|
|
10
|
+
|
|
11
|
+
module RSpec
|
|
12
|
+
module Covers
|
|
13
|
+
module Integration
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def install!(rspec_config)
|
|
17
|
+
return if RSpec::Covers.installed?
|
|
18
|
+
|
|
19
|
+
RSpec::Covers.mark_installed!
|
|
20
|
+
install_expect_hook
|
|
21
|
+
|
|
22
|
+
rspec_config.before(:suite) do
|
|
23
|
+
RSpec::Covers.coverage_probe.start
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
rspec_config.around(:example) do |example|
|
|
27
|
+
RSpec::Covers.run_example(example)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
rspec_config.after(:suite) do
|
|
31
|
+
RSpec::Covers.reporter.finalize_suggestions(RSpec::Covers.configuration)
|
|
32
|
+
RSpec::Covers.reporter.write_json(RSpec::Covers.configuration.report_path)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def install_expect_hook
|
|
37
|
+
return unless defined?(::RSpec::Matchers)
|
|
38
|
+
return if ::RSpec::Matchers.ancestors.include?(ExpectHook)
|
|
39
|
+
|
|
40
|
+
::RSpec::Matchers.prepend(ExpectHook)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module ExpectHook
|
|
45
|
+
def expect(actual = ::RSpec::Expectations::ExpectationTarget::UndefinedValue, &block)
|
|
46
|
+
RSpec::Covers.record_expectation_actual(actual) unless actual.equal?(
|
|
47
|
+
::RSpec::Expectations::ExpectationTarget::UndefinedValue
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Covers
|
|
5
|
+
module MetadataReader
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def declarations_for(example, key)
|
|
9
|
+
metadata_chain(example).flat_map do |metadata|
|
|
10
|
+
metadata.key?(key) ? normalize(metadata[key]) : []
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def metadata_chain(example)
|
|
15
|
+
groups = []
|
|
16
|
+
current = example.metadata[:parent_example_group]
|
|
17
|
+
|
|
18
|
+
while current
|
|
19
|
+
groups << current
|
|
20
|
+
current = current[:parent_example_group]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
groups.reverse << example.metadata
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def normalize(value)
|
|
27
|
+
return [] if value.nil? || value == false
|
|
28
|
+
return value.flat_map { |item| normalize(item) } if value.is_a?(Array)
|
|
29
|
+
|
|
30
|
+
[value]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Covers
|
|
5
|
+
MethodEntry = Struct.new(:label, :file, :line, :region, keyword_init: true) do
|
|
6
|
+
def include?(location)
|
|
7
|
+
region.include?(location)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_h
|
|
11
|
+
{
|
|
12
|
+
label: label,
|
|
13
|
+
file: file,
|
|
14
|
+
line: line
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Covers
|
|
5
|
+
module MethodLabel
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def instance(owner, method_name)
|
|
9
|
+
"#{owner_name(owner)}##{method_name}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def singleton(owner, method_name)
|
|
13
|
+
"#{owner_name(owner)}.#{method_name}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def from_trace_point(trace_point)
|
|
17
|
+
owner = trace_point.defined_class
|
|
18
|
+
|
|
19
|
+
if singleton_owner?(owner)
|
|
20
|
+
singleton(trace_point.self.is_a?(Module) ? trace_point.self : trace_point.self.class, trace_point.method_id)
|
|
21
|
+
else
|
|
22
|
+
instance(owner, trace_point.method_id)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def owner_name(owner)
|
|
27
|
+
return owner.name if owner.respond_to?(:name) && owner.name
|
|
28
|
+
|
|
29
|
+
owner.to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def singleton_owner?(owner)
|
|
33
|
+
owner.inspect.start_with?("#<Class:")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|