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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "../method_label"
6
+
7
+ module RSpec
8
+ module Covers
9
+ module Probe
10
+ Call = Struct.new(:label, :file, :line, :event, keyword_init: true) do
11
+ def to_h
12
+ { label: label, file: file, line: line, event: event }
13
+ end
14
+ end
15
+
16
+ Expectation = Struct.new(:actual_object_id, :call_index, keyword_init: true) do
17
+ def to_h
18
+ { actual_object_id: actual_object_id, call_index: call_index }
19
+ end
20
+ end
21
+
22
+ class CallLog
23
+ attr_reader :calls, :returns_by_object_id, :expectations
24
+
25
+ def initialize(calls:, returns_by_object_id:, expectations: [])
26
+ @calls = calls
27
+ @returns_by_object_id = returns_by_object_id
28
+ @expectations = expectations
29
+ end
30
+
31
+ def last_call
32
+ calls.last
33
+ end
34
+
35
+ def calls_before_expectations
36
+ expectations.filter_map do |expectation|
37
+ next if expectation.call_index.to_i <= 0
38
+
39
+ calls[expectation.call_index - 1]
40
+ end
41
+ end
42
+
43
+ def labels_before_expectations
44
+ calls_before_expectations.map(&:label).uniq
45
+ end
46
+
47
+ def labels
48
+ calls.map(&:label)
49
+ end
50
+
51
+ def expectation_object_ids
52
+ expectations.map(&:actual_object_id)
53
+ end
54
+
55
+ def return_labels_for(object_ids)
56
+ object_ids.flat_map { |object_id| returns_by_object_id.fetch(object_id, []) }.uniq
57
+ end
58
+ end
59
+
60
+ class CallLogProbe
61
+ def initialize(config)
62
+ @config = config
63
+ end
64
+
65
+ def start_example
66
+ @calls = []
67
+ @call_stack = []
68
+ @expectations = []
69
+ @returns_by_object_id = Hash.new { |hash, key| hash[key] = Set.new }
70
+ @trace_point = TracePoint.new(:call, :return) { |trace_point| record(trace_point) }
71
+ @trace_point.enable
72
+ end
73
+
74
+ def active?
75
+ !@trace_point.nil?
76
+ end
77
+
78
+ def record_expectation(actual)
79
+ return unless active?
80
+
81
+ @expectations << Expectation.new(
82
+ actual_object_id: actual.__id__,
83
+ call_index: @calls.length
84
+ )
85
+ end
86
+
87
+ def stop_example
88
+ @trace_point&.disable
89
+
90
+ CallLog.new(
91
+ calls: @calls || [],
92
+ returns_by_object_id: @returns_by_object_id || {},
93
+ expectations: @expectations || []
94
+ )
95
+ ensure
96
+ @trace_point = nil
97
+ @calls = nil
98
+ @call_stack = nil
99
+ @expectations = nil
100
+ @returns_by_object_id = nil
101
+ end
102
+
103
+ private
104
+
105
+ def record(trace_point)
106
+ return unless @config.production_file?(trace_point.path)
107
+
108
+ case trace_point.event
109
+ when :call
110
+ call = Call.new(
111
+ label: MethodLabel.from_trace_point(trace_point),
112
+ file: File.expand_path(trace_point.path),
113
+ line: trace_point.lineno,
114
+ event: :call
115
+ )
116
+ @calls << call
117
+ @call_stack << call
118
+ when :return
119
+ record_return(trace_point)
120
+ end
121
+ end
122
+
123
+ def record_return(trace_point)
124
+ call = @call_stack.pop
125
+ return unless call
126
+
127
+ @returns_by_object_id[trace_point.return_value.__id__] << call.label
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+
5
+ require_relative "../code_location"
6
+
7
+ module RSpec
8
+ module Covers
9
+ module Probe
10
+ class CoverageProbe
11
+ def initialize(config)
12
+ @config = config
13
+ @started = false
14
+ end
15
+
16
+ def start
17
+ return if @started
18
+
19
+ Coverage.start(lines: true) unless Coverage.running?
20
+ @started = true
21
+ end
22
+
23
+ def snapshot
24
+ start
25
+ normalize(Coverage.peek_result)
26
+ end
27
+
28
+ def difference(before_snapshot, after_snapshot)
29
+ after_snapshot.each_with_object(Set.new) do |(file, lines), executed|
30
+ next unless @config.production_file?(file)
31
+
32
+ previous = before_snapshot.fetch(file, [])
33
+ lines.each_with_index do |count, index|
34
+ next unless count && count > previous.fetch(index, 0).to_i
35
+
36
+ executed << CodeLocation.new(file: file, line: index + 1)
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def normalize(raw_result)
44
+ raw_result.each_with_object({}) do |(file, payload), normalized|
45
+ normalized[File.expand_path(file)] = lines_for(payload)
46
+ end
47
+ end
48
+
49
+ def lines_for(payload)
50
+ return payload if payload.is_a?(Array)
51
+
52
+ payload[:lines] || payload["lines"] || []
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "code_location"
6
+ require_relative "method_entry"
7
+ require_relative "method_label"
8
+ require_relative "source_range"
9
+ require_relative "static_method_scanner"
10
+
11
+ module RSpec
12
+ module Covers
13
+ class ProductionInventory
14
+ def initialize(config)
15
+ @config = config
16
+ end
17
+
18
+ def loaded_methods
19
+ @loaded_methods ||= ObjectSpace.each_object(Module).flat_map do |mod|
20
+ method_entries_for(mod)
21
+ end.uniq(&:label)
22
+ end
23
+
24
+ def methods
25
+ @methods ||= (loaded_methods + StaticMethodScanner.new(@config).scan).uniq(&:label)
26
+ end
27
+
28
+ def methods_for_locations(locations)
29
+ locations.flat_map do |location|
30
+ methods.select { |entry| entry.include?(location) }
31
+ end.uniq(&:label)
32
+ end
33
+
34
+ def methods_by_label(labels)
35
+ wanted = labels.to_set
36
+ methods.select { |entry| wanted.include?(entry.label) }
37
+ end
38
+
39
+ def orphan_methods(declared_regions)
40
+ declared_labels = declared_regions.map(&:label).to_set
41
+ methods.reject { |entry| declared_labels.include?(entry.label) }
42
+ end
43
+
44
+ private
45
+
46
+ def method_entries_for(mod)
47
+ instance_method_entries_for(mod) + singleton_method_entries_for(mod)
48
+ end
49
+
50
+ def instance_method_entries_for(mod)
51
+ own_instance_methods(mod).filter_map do |method_name|
52
+ callable = mod.instance_method(method_name)
53
+ method_entry(callable, MethodLabel.instance(mod, method_name))
54
+ rescue NameError
55
+ nil
56
+ end
57
+ end
58
+
59
+ def singleton_method_entries_for(mod)
60
+ mod.singleton_methods(false).filter_map do |method_name|
61
+ method_entry(mod.method(method_name), MethodLabel.singleton(mod, method_name))
62
+ rescue NameError
63
+ nil
64
+ end
65
+ end
66
+
67
+ def method_entry(callable, label)
68
+ source = callable.source_location
69
+ return unless source
70
+
71
+ file, line = source
72
+ return unless @config.production_file?(file)
73
+
74
+ last_line = SourceRange.last_line(callable) || line
75
+ region = CodeRegion.new(file: file, lines: line..last_line, label: label)
76
+
77
+ MethodEntry.new(label: label, file: File.expand_path(file), line: line, region: region)
78
+ rescue ArgumentError
79
+ nil
80
+ end
81
+
82
+ def own_instance_methods(mod)
83
+ mod.instance_methods(false) +
84
+ mod.private_instance_methods(false) +
85
+ mod.protected_instance_methods(false)
86
+ end
87
+
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rake"
5
+
6
+ require_relative "evaluation"
7
+
8
+ module RSpec
9
+ module Covers
10
+ class RakeTask
11
+ include ::Rake::DSL
12
+
13
+ def self.install
14
+ new.install
15
+ end
16
+
17
+ def install
18
+ namespace :covers do
19
+ desc "Print an rspec-covers JSON report summary"
20
+ task :report, [:path] do |_task, args|
21
+ path = args[:path] || RSpec::Covers.configuration.report_path || ".rspec-covers/result.json"
22
+ report = JSON.parse(File.read(path))
23
+ summary = report.fetch("summary")
24
+
25
+ puts "examples: #{summary.fetch("examples")}"
26
+ puts "risky: #{summary.fetch("risky")}"
27
+ puts "suggestions: #{summary.fetch("suggestions")}"
28
+ puts "declaration_warnings: #{summary.fetch("declaration_warnings", 0)}"
29
+ puts "orphan_methods: #{summary.fetch("orphan_methods", 0)}"
30
+ end
31
+
32
+ desc "Print covers metadata suggestions from an rspec-covers JSON report"
33
+ task :suggest, [:path] do |_task, args|
34
+ path = args[:path] || RSpec::Covers.configuration.report_path || ".rspec-covers/result.json"
35
+ report = JSON.parse(File.read(path))
36
+
37
+ report.fetch("examples").each do |example|
38
+ example.fetch("suggestions").each do |suggestion|
39
+ puts "#{example.fetch("file")}:#{example.fetch("line")}"
40
+ puts " suggestion: covers: #{suggestion.fetch("label").inspect} " \
41
+ "(score #{suggestion.fetch("score")}: #{suggestion.fetch("reasons").join(" + ")})"
42
+ end
43
+ end
44
+ end
45
+
46
+ desc "Evaluate suggestion precision/recall/F1 against ground truth JSON"
47
+ task :evaluate, [:ground_truth, :predictions] do |_task, args|
48
+ unless args[:ground_truth] && args[:predictions]
49
+ raise ArgumentError, "usage: rake covers:evaluate[ground_truth.json,predictions.json]"
50
+ end
51
+
52
+ result = Evaluation.evaluate(
53
+ ground_truth_path: args[:ground_truth],
54
+ predictions_path: args[:predictions]
55
+ )
56
+
57
+ puts JSON.pretty_generate(result)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ require_relative "declaration_validation"
7
+ require_relative "production_inventory"
8
+ require_relative "tracer/composite"
9
+ require_relative "tracer/dynamic_corpus"
10
+
11
+ module RSpec
12
+ module Covers
13
+ ExampleResult = Struct.new(
14
+ :id,
15
+ :file,
16
+ :line,
17
+ :description,
18
+ :verdict,
19
+ :suggestions,
20
+ :declaration_validation,
21
+ :declaration,
22
+ :example,
23
+ :call_log,
24
+ :executed_locations,
25
+ :measured_locations,
26
+ :unchecked_locations,
27
+ keyword_init: true
28
+ ) do
29
+ def risky?
30
+ verdict.risky?
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ id: id,
36
+ file: file,
37
+ line: line,
38
+ description: description,
39
+ verdict: verdict.to_h,
40
+ suggestions: suggestions.map(&:to_h),
41
+ declaration_validation: declaration_validation&.to_h,
42
+ measured_locations: Array(measured_locations).map(&:to_h),
43
+ unchecked_locations: Array(unchecked_locations).map(&:to_h),
44
+ declaration: {
45
+ covers: declaration.covers.map(&:to_h),
46
+ uses: declaration.uses.map(&:to_h),
47
+ unresolved: declaration.unresolved.map(&:to_s)
48
+ }
49
+ }
50
+ end
51
+ end
52
+
53
+ class Reporter
54
+ attr_reader :results
55
+
56
+ def initialize
57
+ @results = []
58
+ end
59
+
60
+ def record(result)
61
+ results << result
62
+ end
63
+
64
+ def risky_results
65
+ results.select(&:risky?)
66
+ end
67
+
68
+ def suggestions
69
+ results.flat_map do |result|
70
+ result.suggestions.map { |suggestion| [result, suggestion] }
71
+ end
72
+ end
73
+
74
+ def declared_regions
75
+ results.flat_map { |result| result.declaration.covers }
76
+ end
77
+
78
+ def finalize_suggestions(config)
79
+ return unless config.suggest || config.validate_declarations
80
+
81
+ corpus = Tracer::DynamicCorpus.new
82
+ results.each do |result|
83
+ corpus.record(result.id, result.call_log.labels) if result.call_log
84
+ end
85
+
86
+ finalize_declaration_validations(config, corpus)
87
+ return unless config.suggest
88
+
89
+ results.each do |result|
90
+ next unless result.example
91
+ next if result.declaration.declared?
92
+
93
+ result.suggestions = Tracer::Composite.new(config, corpus: corpus).suggest(
94
+ example: result.example,
95
+ call_log: result.call_log,
96
+ executed_locations: result.executed_locations || []
97
+ )
98
+ end
99
+ end
100
+
101
+ def validation_warnings
102
+ results.select do |result|
103
+ validation = result.declaration_validation
104
+ validation && !validation.valid?
105
+ end
106
+ end
107
+
108
+ def orphan_methods
109
+ ProductionInventory.new(RSpec::Covers.configuration).orphan_methods(declared_regions)
110
+ end
111
+
112
+ def to_h
113
+ orphans = orphan_methods
114
+
115
+ {
116
+ version: VERSION,
117
+ summary: {
118
+ examples: results.length,
119
+ risky: risky_results.length,
120
+ suggestions: suggestions.length,
121
+ declaration_warnings: validation_warnings.length,
122
+ orphan_methods: orphans.length
123
+ },
124
+ orphan_methods: orphans.map(&:to_h),
125
+ examples: results.map(&:to_h)
126
+ }
127
+ end
128
+
129
+ def write_json(path)
130
+ return unless path
131
+
132
+ FileUtils.mkdir_p(File.dirname(path))
133
+ File.write(path, JSON.pretty_generate(to_h))
134
+ end
135
+
136
+ private
137
+
138
+ def finalize_declaration_validations(config, corpus)
139
+ results.each do |result|
140
+ next unless result.example
141
+ next unless result.declaration.declared?
142
+
143
+ result.declaration_validation = validation_for(result, config, corpus)
144
+ end
145
+ end
146
+
147
+ def validation_for(result, config, corpus)
148
+ suggestions = Tracer::Composite.new(config, corpus: corpus).suggest(
149
+ example: result.example,
150
+ call_log: result.call_log,
151
+ executed_locations: result.executed_locations || [],
152
+ limit: nil
153
+ )
154
+
155
+ DeclarationValidator.new(config).call(
156
+ declaration: result.declaration,
157
+ suggestions: suggestions
158
+ )
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Covers
5
+ module SourceRange
6
+ module_function
7
+
8
+ def last_line(callable)
9
+ ast_last_line(callable) || method_source_last_line(callable)
10
+ end
11
+
12
+ def ast_last_line(callable)
13
+ return unless defined?(RubyVM::AbstractSyntaxTree)
14
+
15
+ RubyVM::AbstractSyntaxTree.of(callable).last_lineno
16
+ rescue ArgumentError, RuntimeError, TypeError
17
+ nil
18
+ end
19
+
20
+ def method_source_last_line(callable)
21
+ source_location = callable.source_location
22
+ return unless source_location
23
+
24
+ require "method_source"
25
+ return unless callable.respond_to?(:source)
26
+
27
+ source_location.last + callable.source.lines.length - 1
28
+ rescue LoadError, ArgumentError
29
+ nil
30
+ rescue StandardError => error
31
+ raise unless defined?(MethodSource::SourceNotFoundError) && error.is_a?(MethodSource::SourceNotFoundError)
32
+
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end