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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +202 -0
  4. data/data/evaluation/ground_truth.example.json +26 -0
  5. data/lib/minitest/covers.rb +133 -0
  6. data/lib/rspec/covers/checked_coverage.rb +27 -0
  7. data/lib/rspec/covers/code_location.rb +49 -0
  8. data/lib/rspec/covers/configuration.rb +135 -0
  9. data/lib/rspec/covers/declaration.rb +199 -0
  10. data/lib/rspec/covers/declaration_validation.rb +65 -0
  11. data/lib/rspec/covers/evaluation.rb +197 -0
  12. data/lib/rspec/covers/formatter.rb +70 -0
  13. data/lib/rspec/covers/integration.rb +54 -0
  14. data/lib/rspec/covers/metadata_reader.rb +34 -0
  15. data/lib/rspec/covers/method_entry.rb +19 -0
  16. data/lib/rspec/covers/method_label.rb +37 -0
  17. data/lib/rspec/covers/probe/call_log_probe.rb +132 -0
  18. data/lib/rspec/covers/probe/coverage_probe.rb +57 -0
  19. data/lib/rspec/covers/production_inventory.rb +90 -0
  20. data/lib/rspec/covers/rake_task.rb +63 -0
  21. data/lib/rspec/covers/reporter.rb +162 -0
  22. data/lib/rspec/covers/source_range.rb +37 -0
  23. data/lib/rspec/covers/static_method_scanner.rb +184 -0
  24. data/lib/rspec/covers/strict_verdict.rb +107 -0
  25. data/lib/rspec/covers/tracer/composite.rb +73 -0
  26. data/lib/rspec/covers/tracer/dynamic.rb +27 -0
  27. data/lib/rspec/covers/tracer/dynamic_corpus.rb +43 -0
  28. data/lib/rspec/covers/tracer/lcba.rb +17 -0
  29. data/lib/rspec/covers/tracer/nc.rb +36 -0
  30. data/lib/rspec/covers/tracer/ncc.rb +23 -0
  31. data/lib/rspec/covers/tracer/suggestion.rb +17 -0
  32. data/lib/rspec/covers/tracer/tokenizer.rb +18 -0
  33. data/lib/rspec/covers/version.rb +11 -0
  34. data/lib/rspec/covers.rb +225 -0
  35. data/sig/minitest/covers.rbs +6 -0
  36. data/sig/rspec/covers.rbs +27 -0
  37. 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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Covers
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
8
+
9
+ module Rspec
10
+ Covers = ::RSpec::Covers unless const_defined?(:Covers, false)
11
+ end