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,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