rspec-flake-classifier 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 (31) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +301 -0
  4. data/Rakefile +8 -0
  5. data/exe/rspec-flake +6 -0
  6. data/lib/rspec/flake/classifier/classify/classifier.rb +228 -0
  7. data/lib/rspec/flake/classifier/classify/context.rb +41 -0
  8. data/lib/rspec/flake/classifier/classify/result.rb +44 -0
  9. data/lib/rspec/flake/classifier/cli.rb +298 -0
  10. data/lib/rspec/flake/classifier/configuration.rb +40 -0
  11. data/lib/rspec/flake/classifier/coverage_snapshot.rb +89 -0
  12. data/lib/rspec/flake/classifier/deflaker.rb +102 -0
  13. data/lib/rspec/flake/classifier/evaluation.rb +127 -0
  14. data/lib/rspec/flake/classifier/example_history.rb +24 -0
  15. data/lib/rspec/flake/classifier/features.rb +42 -0
  16. data/lib/rspec/flake/classifier/formatter.rb +194 -0
  17. data/lib/rspec/flake/classifier/integrations.rb +247 -0
  18. data/lib/rspec/flake/classifier/predictor.rb +144 -0
  19. data/lib/rspec/flake/classifier/probe_evidence.rb +77 -0
  20. data/lib/rspec/flake/classifier/rerun/bisect_dependency_search.rb +81 -0
  21. data/lib/rspec/flake/classifier/rerun/isolated_runner.rb +69 -0
  22. data/lib/rspec/flake/classifier/rerun/protocol.rb +83 -0
  23. data/lib/rspec/flake/classifier/rerun/result.rb +82 -0
  24. data/lib/rspec/flake/classifier/runtime_controls.rb +63 -0
  25. data/lib/rspec/flake/classifier/sensitivity.rb +82 -0
  26. data/lib/rspec/flake/classifier/signature.rb +59 -0
  27. data/lib/rspec/flake/classifier/store/jsonl_store.rb +131 -0
  28. data/lib/rspec/flake/classifier/version.rb +13 -0
  29. data/lib/rspec/flake/classifier.rb +285 -0
  30. data/sig/rspec/flake/classifier.rbs +176 -0
  31. metadata +135 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ class Features
6
+ RESOURCE_PATTERNS = {
7
+ "network" => /Net::HTTP|HTTP\.|Faraday|Socket|TCPSocket|UDPSocket|WebMock|VCR/,
8
+ "filesystem" => /\bFile\b|\bDir\b|Tempfile|Pathname|tmpdir|fixture/,
9
+ "time" => /\bTime\b|\bDate\b|Timecop|travel_to|freeze_time|timezone|TZ/,
10
+ "randomness" => /Random|SecureRandom|srand|shuffle|sample|Faker/,
11
+ "concurrency" => /Thread|Fiber|Mutex|Queue|Concurrent::|fork/,
12
+ "async_wait" => /sleep|wait|eventually|Capybara|Selenium/
13
+ }.freeze
14
+
15
+ def extract(file: nil, source: nil, duration: nil, metadata: {})
16
+ source ||= file && File.file?(file) ? File.read(file) : ""
17
+ features = {
18
+ "file" => file,
19
+ "duration" => duration&.to_f,
20
+ "line_count" => source.lines.count,
21
+ "expectation_count" => source.scan(/\b(expect|should|is_expected)\b/).length,
22
+ "example_count" => source.scan(/\b(it|specify|example)\s+["'{(]/).length,
23
+ "metadata" => stringify_keys(metadata)
24
+ }
25
+
26
+ RESOURCE_PATTERNS.each do |name, pattern|
27
+ features["uses_#{name}"] = source.match?(pattern)
28
+ end
29
+
30
+ features
31
+ end
32
+
33
+ private
34
+
35
+ def stringify_keys(hash)
36
+ hash.each_with_object({}) do |(key, value), result|
37
+ result[key.to_s] = value
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "cgi"
5
+ require_relative "../classifier"
6
+
7
+ module RSpec
8
+ module FlakeClassifier
9
+ class Formatter
10
+ ::RSpec::Core::Formatters.register self, :example_failed, :dump_summary if defined?(::RSpec::Core)
11
+
12
+ def initialize(output)
13
+ @output = output
14
+ @failures = []
15
+ end
16
+
17
+ def example_failed(notification)
18
+ example = notification.example
19
+ exception = notification_exception(notification, example)
20
+ signature = Signature.from_exception(exception)
21
+ classification = classify(example, exception)
22
+ @failures << failure_hash(example, exception, signature, classification)
23
+ end
24
+
25
+ def dump_summary(_notification)
26
+ output.puts(JSON.pretty_generate("failures" => failures))
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :output, :failures
32
+
33
+ def classify(example, exception)
34
+ stored = example.metadata[:flake_classifier]&.fetch(:classification, nil)
35
+ return stored if stored
36
+
37
+ Classify::Classifier.new.classify(message: exception.message, backtrace: exception.backtrace).to_h
38
+ end
39
+
40
+ def failure_hash(example, exception, signature, classification)
41
+ labels = Array(classification["labels"]).map { |label| label_category(label) }
42
+ {
43
+ "id" => example.id,
44
+ "description" => example.full_description,
45
+ "file_path" => example.metadata[:file_path],
46
+ "line_number" => example.metadata[:line_number],
47
+ "message" => exception.message,
48
+ "signature" => signature.digest,
49
+ "flaky" => flaky?(classification),
50
+ "labels" => labels,
51
+ "ci" => ci_metadata(example, signature.digest, labels, classification),
52
+ "classification" => classification
53
+ }
54
+ end
55
+
56
+ def flaky?(classification)
57
+ classification["status"] == "flaky" || !Array(classification["labels"]).empty?
58
+ end
59
+
60
+ def notification_exception(notification, example)
61
+ return notification.exception if notification.respond_to?(:exception)
62
+
63
+ example.exception
64
+ end
65
+
66
+ def label_category(label)
67
+ return label.fetch("category", label.fetch(:category, label.to_s)) if label.respond_to?(:fetch)
68
+
69
+ label.to_s
70
+ end
71
+
72
+ def ci_metadata(example, signature, labels, classification)
73
+ {
74
+ "buildkite" => {
75
+ "flaky" => flaky?(classification),
76
+ "flake_signature" => signature,
77
+ "flaky_labels" => labels
78
+ },
79
+ "circleci" => {
80
+ "result" => "failure",
81
+ "source" => example.metadata[:file_path].to_s,
82
+ "run_time" => run_time(example),
83
+ "flaky" => flaky?(classification),
84
+ "flaky_labels" => labels.join(",")
85
+ }
86
+ }
87
+ rescue StandardError
88
+ {
89
+ "buildkite" => { "flaky" => flaky?(classification), "flake_signature" => signature, "flaky_labels" => labels },
90
+ "circleci" => { "result" => "failure", "source" => example.metadata[:file_path].to_s, "flaky" => flaky?(classification), "flaky_labels" => labels.join(",") }
91
+ }
92
+ end
93
+
94
+ def run_time(example)
95
+ return nil unless example.respond_to?(:execution_result)
96
+ return nil unless example.execution_result.respond_to?(:run_time)
97
+
98
+ example.execution_result.run_time.to_f
99
+ end
100
+ end
101
+
102
+ class JUnitFormatter
103
+ ::RSpec::Core::Formatters.register self, :example_failed, :example_passed, :dump_summary if defined?(::RSpec::Core)
104
+
105
+ def initialize(output)
106
+ @output = output
107
+ @testcases = []
108
+ end
109
+
110
+ def example_passed(notification)
111
+ @testcases << testcase(notification.example)
112
+ end
113
+
114
+ def example_failed(notification)
115
+ example = notification.example
116
+ exception = notification.respond_to?(:exception) ? notification.exception : example.exception
117
+ signature = Signature.from_exception(exception)
118
+ classification = classify(example, exception)
119
+ @testcases << testcase(
120
+ example,
121
+ failure: exception.message,
122
+ signature: signature.digest,
123
+ classification: classification
124
+ )
125
+ end
126
+
127
+ def dump_summary(_notification)
128
+ output.puts(%(<?xml version="1.0" encoding="UTF-8"?>))
129
+ output.puts(%(<testsuite tests="#{testcases.length}">))
130
+ testcases.each { |attributes| output.puts(render_case(attributes)) }
131
+ output.puts("</testsuite>")
132
+ end
133
+
134
+ private
135
+
136
+ attr_reader :output, :testcases
137
+
138
+ def classify(example, exception)
139
+ stored = example.metadata[:flake_classifier]&.fetch(:classification, nil)
140
+ return stored if stored
141
+
142
+ Classify::Classifier.new.classify(message: exception.message, backtrace: exception.backtrace).to_h
143
+ end
144
+
145
+ def testcase(example, failure: nil, signature: nil, classification: nil)
146
+ labels = Array(classification&.fetch("labels", nil)).map { |label| label_category(label) }
147
+ {
148
+ "classname" => example.metadata[:file_path].to_s,
149
+ "name" => example.full_description,
150
+ "file" => example.metadata[:file_path].to_s,
151
+ "line" => example.metadata[:line_number].to_s,
152
+ "flaky" => (!labels.empty?).to_s,
153
+ "flaky_labels" => labels.join(","),
154
+ "flake_signature" => signature.to_s,
155
+ "properties" => {
156
+ "buildkite.flaky" => (!labels.empty?).to_s,
157
+ "buildkite.flaky_labels" => labels.join(","),
158
+ "buildkite.flake_signature" => signature.to_s,
159
+ "circleci.flaky" => (!labels.empty?).to_s,
160
+ "circleci.flaky_labels" => labels.join(","),
161
+ "circleci.source" => example.metadata[:file_path].to_s
162
+ },
163
+ "failure" => failure
164
+ }
165
+ end
166
+
167
+ def render_case(attributes)
168
+ failure = attributes.delete("failure")
169
+ properties = attributes.delete("properties") || {}
170
+ xml_attributes = attributes.map do |key, value|
171
+ %(#{key}="#{CGI.escapeHTML(value.to_s)}")
172
+ end.join(" ")
173
+ return %( <testcase #{xml_attributes}>#{render_properties(properties)}</testcase>) unless failure
174
+
175
+ %( <testcase #{xml_attributes}>#{render_properties(properties)}<failure>#{CGI.escapeHTML(failure)}</failure></testcase>)
176
+ end
177
+
178
+ def label_category(label)
179
+ return label.fetch("category", label.fetch(:category, label.to_s)) if label.respond_to?(:fetch)
180
+
181
+ label.to_s
182
+ end
183
+
184
+ def render_properties(properties)
185
+ return "" if properties.empty?
186
+
187
+ values = properties.map do |key, value|
188
+ %( <property name="#{CGI.escapeHTML(key.to_s)}" value="#{CGI.escapeHTML(value.to_s)}"/>)
189
+ end.join
190
+ "<properties>#{values}</properties>"
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ module Integrations
6
+ module_function
7
+
8
+ def coverage_for(example)
9
+ RSpecCovers.coverage_for(example)
10
+ end
11
+
12
+ def probe_for(example)
13
+ RSpecHermetic.probe_for(example)
14
+ end
15
+
16
+ def tag_ci_execution(example:, signature:, classification:)
17
+ BuildkiteCollector.tag_execution(example: example, signature: signature, classification: classification)
18
+ end
19
+
20
+ def constant(name)
21
+ name.to_s.split("::").reduce(Object) do |namespace, const_name|
22
+ return nil unless namespace.const_defined?(const_name, false)
23
+
24
+ namespace.const_get(const_name, false)
25
+ end
26
+ rescue NameError
27
+ nil
28
+ end
29
+
30
+ def try_require(feature)
31
+ require feature
32
+ true
33
+ rescue LoadError
34
+ false
35
+ end
36
+
37
+ def call_first(receivers, methods, *args)
38
+ Array(receivers).compact.each do |receiver|
39
+ Array(methods).each do |method_name|
40
+ next unless receiver.respond_to?(method_name)
41
+
42
+ value = receiver.public_send(method_name, *args)
43
+ return value if value
44
+ rescue ArgumentError
45
+ next
46
+ end
47
+ end
48
+ nil
49
+ end
50
+
51
+ module RSpecCovers
52
+ module_function
53
+
54
+ def coverage_for(example)
55
+ Integrations.try_require("rspec/covers")
56
+ result = reporter_result(example)
57
+ return line_hash(result.executed_locations) if result&.respond_to?(:executed_locations)
58
+
59
+ receivers = [
60
+ Integrations.constant("RSpec::Covers"),
61
+ Integrations.constant("RSpec::Covers::Probe"),
62
+ Integrations.constant("RSpec::Covers::Store"),
63
+ Integrations.constant("RSpec::Covers::Coverage"),
64
+ Integrations.constant("Rspec::Covers")
65
+ ]
66
+ Integrations.call_first(
67
+ receivers,
68
+ %i[coverage_for covered_lines_for example_coverage result_for for_example],
69
+ example
70
+ )
71
+ end
72
+
73
+ def reporter_result(example)
74
+ covers = Integrations.constant("RSpec::Covers")
75
+ return nil unless covers&.respond_to?(:reporter)
76
+ return nil unless covers.reporter.respond_to?(:results)
77
+
78
+ covers.reporter.results.reverse.find do |result|
79
+ result.respond_to?(:id) && result.id == example.id ||
80
+ result.respond_to?(:example) && result.example.equal?(example)
81
+ end
82
+ end
83
+
84
+ def line_hash(locations)
85
+ Array(locations).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |location, result|
86
+ file = location.respond_to?(:file) ? location.file : location[:file]
87
+ line = location.respond_to?(:line) ? location.line : location[:line]
88
+ result[relative_path(file)] << line.to_i if file && line
89
+ end.transform_values { |lines| lines.uniq.sort }
90
+ end
91
+
92
+ def relative_path(file)
93
+ path = file.to_s
94
+ return path unless path.start_with?(Dir.pwd)
95
+
96
+ path.delete_prefix("#{Dir.pwd}/")
97
+ end
98
+ end
99
+
100
+ module RSpecHermetic
101
+ module_function
102
+
103
+ def probe_for(example)
104
+ Integrations.try_require("rspec/hermetic")
105
+ record = runner_record(example)
106
+ return normalize_record(record) if record
107
+
108
+ receivers = [
109
+ Integrations.constant("RSpec::Hermetic"),
110
+ Integrations.constant("RSpec::Hermetic::Forensic"),
111
+ Integrations.constant("RSpec::Hermetic::Probe"),
112
+ Integrations.constant("Rspec::Hermetic")
113
+ ]
114
+ value = Integrations.call_first(
115
+ receivers,
116
+ %i[evidence_for forensic_for probe_for result_for for_example],
117
+ example
118
+ )
119
+ return normalize_hash(value) if value
120
+
121
+ collect_individual(receivers, example)
122
+ end
123
+
124
+ def collect_individual(receivers, example)
125
+ {
126
+ resources: Integrations.call_first(receivers, %i[resources_for resource_leaks_for leaks_for], example),
127
+ files: Integrations.call_first(receivers, %i[files_for filesystem_for], example),
128
+ sockets: Integrations.call_first(receivers, %i[sockets_for network_for], example)
129
+ }.compact
130
+ end
131
+
132
+ def runner_record(example)
133
+ hermetic = Integrations.constant("RSpec::Hermetic")
134
+ return nil unless hermetic&.respond_to?(:runner)
135
+ return nil unless hermetic.runner&.respond_to?(:records)
136
+
137
+ hermetic.runner.records.reverse.find do |record|
138
+ candidate = record[:example] || record["example"]
139
+ candidate.equal?(example) || candidate&.respond_to?(:id) && candidate.id == example.id
140
+ end
141
+ end
142
+
143
+ def normalize_record(record)
144
+ changes = Array((record[:changes] || record["changes"])&.changes)
145
+ evidence = changes.map { |change| change_summary(change) }
146
+ {
147
+ resources: evidence_for(changes, /resource|runtime|global|constant|env|random|time|rails/i),
148
+ files: evidence_for(changes, /file|filesystem|tmp|dir/i),
149
+ sockets: evidence_for(changes, /socket|network|tcp|udp|http/i),
150
+ source: evidence.join("\n")
151
+ }.compact
152
+ end
153
+
154
+ def evidence_for(changes, pattern)
155
+ changes.select { |change| change.probe.to_s.match?(pattern) || change.key.to_s.match?(pattern) }
156
+ .map { |change| change_summary(change) }
157
+ end
158
+
159
+ def change_summary(change)
160
+ "#{change.probe}:#{change.key} #{stable_value(change.before)} -> #{stable_value(change.after)}"
161
+ end
162
+
163
+ def stable_value(value)
164
+ return "<missing>" if missing_value?(value)
165
+
166
+ value.inspect
167
+ end
168
+
169
+ def missing_value?(value)
170
+ change = Integrations.constant("RSpec::Hermetic::Change")
171
+ change && change.const_defined?(:MISSING) && value.equal?(change.const_get(:MISSING))
172
+ end
173
+
174
+ def normalize_hash(value)
175
+ return value.to_h if value.respond_to?(:to_h)
176
+
177
+ {
178
+ resources: value.respond_to?(:resources) ? value.resources : nil,
179
+ files: value.respond_to?(:files) ? value.files : nil,
180
+ sockets: value.respond_to?(:sockets) ? value.sockets : nil,
181
+ source: value.respond_to?(:source) ? value.source : nil
182
+ }.compact
183
+ end
184
+ end
185
+
186
+ module BuildkiteCollector
187
+ module_function
188
+
189
+ def tag_execution(example:, signature:, classification:)
190
+ labels = labels_for(classification)
191
+ tags = {
192
+ "rspec_flake_classifier.flaky" => flaky?(classification, labels).to_s,
193
+ "rspec_flake_classifier.labels" => labels.join(","),
194
+ "rspec_flake_classifier.signature" => signature.digest.to_s
195
+ }
196
+ return if tag_current_execution(tags)
197
+
198
+ tag_recorded_trace(example, tags)
199
+ rescue StandardError
200
+ nil
201
+ end
202
+
203
+ def tag_current_execution(tags)
204
+ collector = Integrations.constant("Buildkite::TestCollector")
205
+ return false unless collector&.respond_to?(:tag_execution)
206
+
207
+ tags.each { |key, value| collector.tag_execution(key, value) }
208
+ true
209
+ rescue StandardError
210
+ false
211
+ end
212
+
213
+ def tag_recorded_trace(example, tags)
214
+ trace = buildkite_trace(example)
215
+ return unless trace&.respond_to?(:tags)
216
+ return unless trace.tags.respond_to?(:[]=)
217
+
218
+ tags.each { |key, value| trace.tags[key] = value }
219
+ end
220
+
221
+ def buildkite_trace(example)
222
+ collector = Integrations.constant("Buildkite::TestCollector")
223
+ uploader = collector&.respond_to?(:uploader) ? collector.uploader : nil
224
+ return nil unless uploader&.respond_to?(:traces)
225
+
226
+ uploader.traces[example.id]
227
+ end
228
+
229
+ def labels_for(classification)
230
+ hash = classification.respond_to?(:to_h) ? classification.to_h : classification
231
+ Array(hash&.fetch("labels", nil)).map do |label|
232
+ if label.respond_to?(:fetch)
233
+ label.fetch("category", label.fetch(:category, label.to_s)).to_s
234
+ else
235
+ label.to_s
236
+ end
237
+ end
238
+ end
239
+
240
+ def flaky?(classification, labels)
241
+ hash = classification.respond_to?(:to_h) ? classification.to_h : classification
242
+ hash&.fetch("status", nil) == "flaky" || labels.any?
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RSpec
6
+ module FlakeClassifier
7
+ class Predictor
8
+ WEIGHTS = {
9
+ "duration" => 0.15,
10
+ "uses_network" => 0.2,
11
+ "uses_filesystem" => 0.08,
12
+ "uses_time" => 0.15,
13
+ "uses_randomness" => 0.15,
14
+ "uses_concurrency" => 0.17,
15
+ "uses_async_wait" => 0.2
16
+ }.freeze
17
+
18
+ attr_reader :weights
19
+
20
+ def self.train(examples)
21
+ new(weights: Trainer.new(Array(examples)).weights)
22
+ end
23
+
24
+ def self.load(path)
25
+ payload = JSON.parse(File.read(path))
26
+ new(weights: payload.fetch("weights", payload))
27
+ end
28
+
29
+ def initialize(weights: WEIGHTS)
30
+ @weights = normalize_weights(weights)
31
+ end
32
+
33
+ def score(features)
34
+ score = weights.sum do |key, weight|
35
+ feature_score(features, key) * weight
36
+ end
37
+ [[score, 0.0].max, 1.0].min.round(4)
38
+ end
39
+
40
+ def rank(feature_sets)
41
+ Array(feature_sets).map do |features|
42
+ features.merge("flake_score" => score(features), "priority" => priority(score(features)))
43
+ end.sort_by { |features| -features.fetch("flake_score") }
44
+ end
45
+
46
+ def to_h
47
+ { "weights" => weights }
48
+ end
49
+
50
+ private
51
+
52
+ def feature_score(features, key)
53
+ value = features[key] || features[key.to_sym]
54
+ return duration_score(value) if key == "duration"
55
+
56
+ value ? 1.0 : 0.0
57
+ end
58
+
59
+ def duration_score(value)
60
+ duration = Float(value || 0.0)
61
+ return 0.0 unless duration.positive?
62
+
63
+ [duration / 10.0, 1.0].min
64
+ rescue ArgumentError, TypeError
65
+ 0.0
66
+ end
67
+
68
+ def priority(score)
69
+ return "high" if score >= 0.45
70
+ return "medium" if score >= 0.2
71
+
72
+ "low"
73
+ end
74
+
75
+ def normalize_weights(input)
76
+ weights = WEIGHTS.merge(stringify_keys(input)).transform_values { |value| Float(value) }
77
+ total = weights.values.sum
78
+ return WEIGHTS if total <= 0.0
79
+
80
+ weights.transform_values { |value| (value / total).round(6) }
81
+ rescue ArgumentError, TypeError
82
+ WEIGHTS
83
+ end
84
+
85
+ def stringify_keys(hash)
86
+ hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
87
+ end
88
+
89
+ class Trainer
90
+ def initialize(examples)
91
+ @examples = examples
92
+ end
93
+
94
+ def weights
95
+ positives = examples.select { |example| flaky?(example) }
96
+ negatives = examples.reject { |example| flaky?(example) }
97
+ return WEIGHTS if positives.empty? || negatives.empty?
98
+
99
+ raw = WEIGHTS.keys.to_h do |key|
100
+ [key, [average(positives, key) - average(negatives, key), 0.0].max]
101
+ end
102
+ normalize(raw)
103
+ end
104
+
105
+ private
106
+
107
+ attr_reader :examples
108
+
109
+ def flaky?(example)
110
+ value = example["flaky"] || example[:flaky] || example["label"] || example[:label] ||
111
+ example["status"] || example[:status]
112
+ value == true || value.to_s == "flaky"
113
+ end
114
+
115
+ def average(records, key)
116
+ records.sum { |record| feature_score(record, key) } / records.length.to_f
117
+ end
118
+
119
+ def feature_score(record, key)
120
+ value = record[key] || record[key.to_sym]
121
+ return duration_score(value) if key == "duration"
122
+
123
+ value ? 1.0 : 0.0
124
+ end
125
+
126
+ def duration_score(value)
127
+ duration = Float(value || 0.0)
128
+ return 0.0 unless duration.positive?
129
+
130
+ [duration / 10.0, 1.0].min
131
+ rescue ArgumentError, TypeError
132
+ 0.0
133
+ end
134
+
135
+ def normalize(raw)
136
+ total = raw.values.sum
137
+ return WEIGHTS if total <= 0.0
138
+
139
+ raw.transform_values { |value| (value / total).round(6) }
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "integrations"
4
+
5
+ module RSpec
6
+ module FlakeClassifier
7
+ class ProbeEvidence
8
+ EMPTY = {
9
+ resources: [],
10
+ files: [],
11
+ sockets: [],
12
+ source: nil
13
+ }.freeze
14
+
15
+ def initialize(provider: nil)
16
+ @provider = provider
17
+ end
18
+
19
+ def collect(example)
20
+ data = from_integration(example).merge(from_provider(example)) do |_key, integration_value, provider_value|
21
+ merge_value(integration_value, provider_value)
22
+ end.merge(from_metadata(example)) do |_key, provider_value, metadata_value|
23
+ merge_value(provider_value, metadata_value)
24
+ end
25
+ EMPTY.merge(data)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :provider
31
+
32
+ def from_integration(example)
33
+ normalize(Integrations.probe_for(example))
34
+ end
35
+
36
+ def from_provider(example)
37
+ return {} unless provider
38
+
39
+ normalize(provider.call(example))
40
+ rescue StandardError => e
41
+ { resources: ["probe provider failed: #{e.class}: #{e.message}"] }
42
+ end
43
+
44
+ def from_metadata(example)
45
+ metadata = example.metadata
46
+ normalize(
47
+ resources: metadata[:flake_classifier_resources] || metadata[:hermetic_resources],
48
+ files: metadata[:flake_classifier_files] || metadata[:hermetic_files],
49
+ sockets: metadata[:flake_classifier_sockets] || metadata[:hermetic_sockets],
50
+ source: metadata[:flake_classifier_source]
51
+ )
52
+ end
53
+
54
+ def normalize(data)
55
+ hash = data || {}
56
+ {
57
+ resources: array_value(hash, :resources),
58
+ files: array_value(hash, :files),
59
+ sockets: array_value(hash, :sockets),
60
+ source: hash[:source] || hash["source"]
61
+ }.compact
62
+ end
63
+
64
+ def array_value(hash, key)
65
+ Array(hash[key] || hash[key.to_s]).compact
66
+ end
67
+
68
+ def merge_value(left, right)
69
+ return right if left.nil?
70
+ return left if right.nil?
71
+ return [*Array(left), *Array(right)].uniq unless left.is_a?(String) || right.is_a?(String)
72
+
73
+ [left, right].compact.join("\n")
74
+ end
75
+ end
76
+ end
77
+ end