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,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
require_relative "code_location"
|
|
6
|
+
require_relative "method_entry"
|
|
7
|
+
|
|
8
|
+
module RSpec
|
|
9
|
+
module Covers
|
|
10
|
+
class StaticMethodScanner
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def scan
|
|
16
|
+
@config.production_files.flat_map { |file| scan_file(file) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def scan_file(file)
|
|
22
|
+
source = File.read(file)
|
|
23
|
+
sexp = Ripper.sexp(source)
|
|
24
|
+
return [] unless sexp
|
|
25
|
+
|
|
26
|
+
entries = []
|
|
27
|
+
walk(sexp, [], file, entries, source)
|
|
28
|
+
entries
|
|
29
|
+
rescue Errno::ENOENT
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def walk(node, namespace, file, entries, source, singleton: false)
|
|
34
|
+
return unless node.is_a?(Array)
|
|
35
|
+
|
|
36
|
+
case node.first
|
|
37
|
+
when :module
|
|
38
|
+
walk(node[2], namespace + [constant_name(node[1])].compact, file, entries, source)
|
|
39
|
+
when :class
|
|
40
|
+
walk(node[3], namespace + [constant_name(node[1])].compact, file, entries, source)
|
|
41
|
+
when :sclass
|
|
42
|
+
walk(node[2], namespace, file, entries, source, singleton: self_node?(node[1]))
|
|
43
|
+
when :def
|
|
44
|
+
record_method(node, namespace, file, entries, source, singleton: singleton)
|
|
45
|
+
walk_children(node, namespace, file, entries, source, singleton: singleton)
|
|
46
|
+
when :defs
|
|
47
|
+
record_singleton_method(node, namespace, file, entries, source)
|
|
48
|
+
walk_children(node, namespace, file, entries, source, singleton: singleton)
|
|
49
|
+
when :method_add_block
|
|
50
|
+
record_define_method(node, namespace, file, entries, source, singleton: singleton)
|
|
51
|
+
walk_children(node, namespace, file, entries, source, singleton: singleton)
|
|
52
|
+
else
|
|
53
|
+
walk_children(node, namespace, file, entries, source, singleton: singleton)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def walk_children(node, namespace, file, entries, source, singleton: false)
|
|
58
|
+
node.each { |child| walk(child, namespace, file, entries, source, singleton: singleton) if child.is_a?(Array) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def record_method(node, namespace, file, entries, source, singleton:)
|
|
62
|
+
return if namespace.empty?
|
|
63
|
+
|
|
64
|
+
name_token = node[1]
|
|
65
|
+
method_name = token_value(name_token)
|
|
66
|
+
line = token_line(name_token)
|
|
67
|
+
return unless method_name && line
|
|
68
|
+
|
|
69
|
+
separator = singleton ? "." : "#"
|
|
70
|
+
label = "#{namespace.join("::")}#{separator}#{method_name}"
|
|
71
|
+
entries << method_entry(label, file, line, source)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record_singleton_method(node, namespace, file, entries, source)
|
|
75
|
+
owner = singleton_owner(node[1], namespace)
|
|
76
|
+
name_token = node[3]
|
|
77
|
+
method_name = token_value(name_token)
|
|
78
|
+
line = token_line(name_token)
|
|
79
|
+
return unless owner && method_name && line
|
|
80
|
+
|
|
81
|
+
entries << method_entry("#{owner}.#{method_name}", file, line, source)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def record_define_method(node, namespace, file, entries, source, singleton:)
|
|
85
|
+
return if namespace.empty?
|
|
86
|
+
return unless define_method_call?(node[1])
|
|
87
|
+
|
|
88
|
+
method_name = define_method_name(node[1])
|
|
89
|
+
line = define_method_line(node[1])
|
|
90
|
+
return unless method_name && line
|
|
91
|
+
|
|
92
|
+
separator = singleton ? "." : "#"
|
|
93
|
+
entries << method_entry("#{namespace.join("::")}#{separator}#{method_name}", file, line, source)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def method_entry(label, file, line, source)
|
|
97
|
+
region = CodeRegion.new(file: file, lines: line..method_end_line(source, line), label: label)
|
|
98
|
+
MethodEntry.new(label: label, file: File.expand_path(file), line: line, region: region)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def method_end_line(source, start_line)
|
|
102
|
+
tokens = Ripper.lex(source).select { |token| token[0][0] >= start_line }
|
|
103
|
+
depth = 0
|
|
104
|
+
seen_open = false
|
|
105
|
+
|
|
106
|
+
tokens.each do |position, type, value, _state|
|
|
107
|
+
next unless type == :on_kw
|
|
108
|
+
|
|
109
|
+
if opening_keyword?(value)
|
|
110
|
+
depth += 1
|
|
111
|
+
seen_open = true
|
|
112
|
+
elsif value == "end" && seen_open
|
|
113
|
+
depth -= 1
|
|
114
|
+
return position[0] if depth.zero?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
start_line
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def opening_keyword?(value)
|
|
122
|
+
%w[def class module if unless case begin for while until do].include?(value)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def define_method_call?(node)
|
|
126
|
+
node.is_a?(Array) &&
|
|
127
|
+
node[0] == :method_add_arg &&
|
|
128
|
+
node[1].is_a?(Array) &&
|
|
129
|
+
node[1][0] == :fcall &&
|
|
130
|
+
token_value(node[1][1]) == "define_method"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def define_method_name(node)
|
|
134
|
+
token = define_method_arg_token(node)
|
|
135
|
+
token_value(token)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def define_method_line(node)
|
|
139
|
+
token = define_method_arg_token(node)
|
|
140
|
+
token_line(token)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def define_method_arg_token(node)
|
|
144
|
+
args = node.dig(2, 1, 1)
|
|
145
|
+
first_arg = args&.first
|
|
146
|
+
return first_arg[1][1] if first_arg&.first == :symbol_literal
|
|
147
|
+
return first_arg.dig(1, 1) if first_arg&.first == :string_literal
|
|
148
|
+
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def singleton_owner(node, namespace)
|
|
153
|
+
return namespace.join("::") if self_node?(node) && namespace.any?
|
|
154
|
+
|
|
155
|
+
constant_name(node)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self_node?(node)
|
|
159
|
+
node.is_a?(Array) && node[0] == :var_ref && token_value(node[1]) == "self"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def constant_name(node)
|
|
163
|
+
return unless node.is_a?(Array)
|
|
164
|
+
|
|
165
|
+
case node.first
|
|
166
|
+
when :const_ref, :var_ref
|
|
167
|
+
token_value(node[1])
|
|
168
|
+
when :const_path_ref
|
|
169
|
+
[constant_name(node[1]), token_value(node[2])].compact.join("::")
|
|
170
|
+
when :top_const_ref
|
|
171
|
+
token_value(node[1])
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def token_value(token)
|
|
176
|
+
token[1] if token.is_a?(Array) && token[0].to_s.start_with?("@")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def token_line(token)
|
|
180
|
+
token[2][0] if token.is_a?(Array) && token[2].is_a?(Array)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "production_inventory"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module Covers
|
|
9
|
+
class StrictVerdict
|
|
10
|
+
attr_reader :risky, :reason, :undeclared_locations, :declaration
|
|
11
|
+
|
|
12
|
+
def initialize(risky:, reason:, undeclared_locations:, declaration:)
|
|
13
|
+
@risky = risky
|
|
14
|
+
@reason = reason
|
|
15
|
+
@undeclared_locations = undeclared_locations
|
|
16
|
+
@declaration = declaration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def risky?
|
|
20
|
+
risky
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clean?
|
|
24
|
+
!risky?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def message
|
|
28
|
+
case reason
|
|
29
|
+
when :undeclared_example
|
|
30
|
+
"rspec-covers: example executed production code without covers metadata"
|
|
31
|
+
when :strict_miss
|
|
32
|
+
"rspec-covers: example executed production code outside covers/uses metadata"
|
|
33
|
+
else
|
|
34
|
+
"rspec-covers: clean"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def grouped_evidence(limit: 10, config: RSpec::Covers.configuration)
|
|
39
|
+
undeclared_locations.group_by(&:file).flat_map do |file, locations|
|
|
40
|
+
lines = locations.map(&:line).sort.take(limit)
|
|
41
|
+
methods = ProductionInventory.new(config).methods_for_locations(locations)
|
|
42
|
+
[{
|
|
43
|
+
file: file,
|
|
44
|
+
lines: lines,
|
|
45
|
+
methods: methods.map(&:label),
|
|
46
|
+
note: "coverage from threads spawned inside the example may be included"
|
|
47
|
+
}]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{
|
|
53
|
+
risky: risky?,
|
|
54
|
+
reason: reason,
|
|
55
|
+
undeclared_locations: undeclared_locations.map(&:to_h),
|
|
56
|
+
evidence: grouped_evidence
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.call(executed_locations:, declaration:, config:)
|
|
61
|
+
executed_locations = Set.new(executed_locations)
|
|
62
|
+
|
|
63
|
+
if !declaration.declared?
|
|
64
|
+
risky = config.undeclared == :risky && executed_locations.any?
|
|
65
|
+
return new(
|
|
66
|
+
risky: risky,
|
|
67
|
+
reason: executed_locations.any? ? :undeclared_example : :clean,
|
|
68
|
+
undeclared_locations: executed_locations,
|
|
69
|
+
declaration: declaration
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
allowed = declaration.locations
|
|
74
|
+
misses = executed_locations.reject { |location| allowed.include?(location) }.to_set
|
|
75
|
+
|
|
76
|
+
new(
|
|
77
|
+
risky: config.strict && misses.any?,
|
|
78
|
+
reason: misses.any? ? :strict_miss : :clean,
|
|
79
|
+
undeclared_locations: misses,
|
|
80
|
+
declaration: declaration
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class StrictCoverageError < Error
|
|
86
|
+
attr_reader :verdict
|
|
87
|
+
|
|
88
|
+
def initialize(verdict)
|
|
89
|
+
@verdict = verdict
|
|
90
|
+
super(build_message(verdict))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def build_message(verdict)
|
|
96
|
+
evidence = verdict.grouped_evidence.flat_map do |item|
|
|
97
|
+
lines = [" #{item[:file]}:#{item[:lines].join(",")}"]
|
|
98
|
+
lines << " methods: #{item[:methods].join(", ")}" if item[:methods].any?
|
|
99
|
+
lines << " note: #{item[:note]}" if item[:note]
|
|
100
|
+
lines
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
([verdict.message] + evidence).join("\n")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../production_inventory"
|
|
4
|
+
require_relative "dynamic"
|
|
5
|
+
require_relative "dynamic_corpus"
|
|
6
|
+
require_relative "lcba"
|
|
7
|
+
require_relative "nc"
|
|
8
|
+
require_relative "ncc"
|
|
9
|
+
require_relative "suggestion"
|
|
10
|
+
|
|
11
|
+
module RSpec
|
|
12
|
+
module Covers
|
|
13
|
+
module Tracer
|
|
14
|
+
class Composite
|
|
15
|
+
STRATEGIES = {
|
|
16
|
+
nc: NC,
|
|
17
|
+
ncc: NCC,
|
|
18
|
+
lcba: LCBA,
|
|
19
|
+
dynamic: Dynamic
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(config, corpus: nil)
|
|
23
|
+
@config = config
|
|
24
|
+
@inventory = ProductionInventory.new(config)
|
|
25
|
+
@corpus = corpus
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def suggest(example:, call_log:, executed_locations:, corpus: @corpus, limit: @config.suggestion_limit)
|
|
29
|
+
candidates = candidates_for(call_log, executed_locations)
|
|
30
|
+
return [] if candidates.empty?
|
|
31
|
+
|
|
32
|
+
weighted_scores = Hash.new(0.0)
|
|
33
|
+
reasons = Hash.new { |hash, key| hash[key] = [] }
|
|
34
|
+
|
|
35
|
+
STRATEGIES.each do |name, strategy_class|
|
|
36
|
+
weight = @config.suggestion_weights.fetch(name, 1.0).to_f
|
|
37
|
+
next if weight.zero?
|
|
38
|
+
|
|
39
|
+
strategy_scores = strategy_class.new.score(
|
|
40
|
+
example: example,
|
|
41
|
+
call_log: call_log,
|
|
42
|
+
candidates: candidates,
|
|
43
|
+
corpus: corpus
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
strategy_scores.each do |label, score|
|
|
47
|
+
next unless score.positive?
|
|
48
|
+
|
|
49
|
+
weighted_scores[label] += score * weight
|
|
50
|
+
reasons[label] << name
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ranked = weighted_scores.sort_by { |_, score| -score }
|
|
55
|
+
ranked = ranked.first(limit) if limit
|
|
56
|
+
|
|
57
|
+
ranked.map do |label, score|
|
|
58
|
+
Suggestion.new(label: label, score: score, reasons: reasons[label])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def candidates_for(call_log, executed_locations)
|
|
65
|
+
labels = call_log&.labels || []
|
|
66
|
+
candidates = @inventory.methods_by_label(labels)
|
|
67
|
+
candidates += @inventory.methods_for_locations(executed_locations)
|
|
68
|
+
candidates.uniq(&:label)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Covers
|
|
5
|
+
module Tracer
|
|
6
|
+
class Dynamic
|
|
7
|
+
def score(example:, call_log:, candidates:, corpus: nil, **)
|
|
8
|
+
return corpus_score(example, candidates, corpus) if corpus
|
|
9
|
+
|
|
10
|
+
counts = call_log&.labels&.tally || {}
|
|
11
|
+
max_count = counts.values.max.to_f
|
|
12
|
+
|
|
13
|
+
candidates.each_with_object({}) do |candidate, scores|
|
|
14
|
+
scores[candidate.label] = max_count.positive? ? counts.fetch(candidate.label, 0) / max_count : 0.0
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def corpus_score(example, candidates, corpus)
|
|
21
|
+
labels = candidates.map(&:label)
|
|
22
|
+
corpus.normalize(example.id, labels)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Covers
|
|
5
|
+
module Tracer
|
|
6
|
+
class DynamicCorpus
|
|
7
|
+
def initialize
|
|
8
|
+
@documents = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def record(example_id, labels)
|
|
12
|
+
@documents[example_id] = labels.tally
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def score(example_id, label)
|
|
16
|
+
counts = @documents.fetch(example_id, {})
|
|
17
|
+
max_count = counts.values.max.to_f
|
|
18
|
+
return 0.0 unless max_count.positive?
|
|
19
|
+
|
|
20
|
+
term_frequency = counts.fetch(label, 0) / max_count
|
|
21
|
+
term_frequency * inverse_document_frequency(label)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def normalize(example_id, labels)
|
|
25
|
+
raw_scores = labels.to_h { |label| [label, score(example_id, label)] }
|
|
26
|
+
max_score = raw_scores.values.max.to_f
|
|
27
|
+
return raw_scores.transform_values { 0.0 } unless max_score.positive?
|
|
28
|
+
|
|
29
|
+
raw_scores.transform_values { |value| value / max_score }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def inverse_document_frequency(label)
|
|
35
|
+
total = @documents.length
|
|
36
|
+
document_frequency = @documents.values.count { |counts| counts.key?(label) }
|
|
37
|
+
|
|
38
|
+
Math.log((1.0 + total) / (1.0 + document_frequency)) + 1.0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Covers
|
|
5
|
+
module Tracer
|
|
6
|
+
class LCBA
|
|
7
|
+
def score(call_log:, candidates:, **)
|
|
8
|
+
labels = call_log&.labels_before_expectations || []
|
|
9
|
+
|
|
10
|
+
candidates.each_with_object({}) do |candidate, scores|
|
|
11
|
+
scores[candidate.label] = labels.include?(candidate.label) ? 1.0 : 0.0
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tokenizer"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Covers
|
|
7
|
+
module Tracer
|
|
8
|
+
class NC
|
|
9
|
+
def score(example:, candidates:, **)
|
|
10
|
+
expected_names = names_from_example(example)
|
|
11
|
+
|
|
12
|
+
candidates.each_with_object({}) do |candidate, scores|
|
|
13
|
+
owner = candidate.label.split(/[.#]/).first
|
|
14
|
+
scores[candidate.label] = expected_names.include?(owner) ? 1.0 : 0.0
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def names_from_example(example)
|
|
21
|
+
names = []
|
|
22
|
+
described = example.metadata[:described_class]
|
|
23
|
+
names << described.name if described.respond_to?(:name)
|
|
24
|
+
|
|
25
|
+
file_path = example.metadata[:file_path]
|
|
26
|
+
if file_path
|
|
27
|
+
base = File.basename(file_path, "_spec.rb")
|
|
28
|
+
names << base.split("_").map(&:capitalize).join
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
names.compact
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "tokenizer"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module Covers
|
|
9
|
+
module Tracer
|
|
10
|
+
class NCC
|
|
11
|
+
def score(example:, candidates:, **)
|
|
12
|
+
description_tokens = Tokenizer.call(example.full_description).to_set
|
|
13
|
+
|
|
14
|
+
candidates.each_with_object({}) do |candidate, scores|
|
|
15
|
+
candidate_tokens = Tokenizer.call(candidate.label).to_set
|
|
16
|
+
overlap = description_tokens & candidate_tokens
|
|
17
|
+
scores[candidate.label] = candidate_tokens.empty? ? 0.0 : overlap.length.to_f / candidate_tokens.length
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Covers
|
|
5
|
+
module Tracer
|
|
6
|
+
Suggestion = Struct.new(:label, :score, :reasons, keyword_init: true) do
|
|
7
|
+
def to_h
|
|
8
|
+
{
|
|
9
|
+
label: label,
|
|
10
|
+
score: score.round(4),
|
|
11
|
+
reasons: reasons
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Covers
|
|
5
|
+
module Tracer
|
|
6
|
+
module Tokenizer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(value)
|
|
10
|
+
value.to_s
|
|
11
|
+
.gsub(/([a-z\d])([A-Z])/, '\1 \2')
|
|
12
|
+
.downcase
|
|
13
|
+
.scan(/[a-z0-9]+/)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|