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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 858a4f181049ee08fcd65482a9d162b5c9cb299be9492058d3385625e495a8bc
4
+ data.tar.gz: c8331443bacc0c9f94f09b3bf7a887321a5fe72eb6669ee89eafc3288cc312e5
5
+ SHA512:
6
+ metadata.gz: ed88a38ce6d5acaaec24ef6d786cbe437b26f503cf916062f8dd811ff3b4be780f4d4ad7bc29c7bb4b9f5f045f91172f93f03ff9e2a669ffaa3c9c71d2989327
7
+ data.tar.gz: f83d5bc547db34d0eb1695911f99af512e3eec2b3e69833e578870bbd313b61703e0a721227d782ac160e3b11c684561e5675e8e484facaf2191e835c74b2525
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,301 @@
1
+ # rspec-flake-classifier
2
+
3
+ `rspec-flake-classifier` records failed RSpec examples, normalizes their failure
4
+ signatures, and classifies likely flaky-test causes. It can also rerun examples in
5
+ isolated RSpec subprocesses to separate repeatable regressions from flaky failures.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem "rspec-flake-classifier"
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```bash
24
+ gem install rspec-flake-classifier
25
+ ```
26
+
27
+ Requirements:
28
+
29
+ - Ruby 3.2 or newer
30
+ - RSpec 3.10 or newer
31
+
32
+ Runtime dependencies:
33
+
34
+ - `rspec-core`
35
+ - `rspec-covers`
36
+ - `rspec-hermetic`
37
+
38
+ ## Usage
39
+
40
+ Configure it from `spec/spec_helper.rb` or `spec/rails_helper.rb`:
41
+
42
+ ```ruby
43
+ require "rspec/flake/classifier"
44
+
45
+ RSpec.configure do |config|
46
+ RSpec::FlakeClassifier.configure(config) do |flake|
47
+ flake.store = ".rspec_flake_store"
48
+ flake.auto_rerun = false
49
+ flake.deflaker = true
50
+ flake.run_sensitivity = false
51
+ end
52
+ end
53
+ ```
54
+
55
+ Run your suite normally:
56
+
57
+ ```bash
58
+ bundle exec rspec
59
+ ```
60
+
61
+ Failure records are written to `.rspec_flake_store/failures.jsonl`. Each record
62
+ contains a normalized signature digest, occurrence counts, labels, example IDs,
63
+ and evidence metadata.
64
+
65
+ ### Features
66
+
67
+ - Append-only JSONL failure signature store
68
+ - iDFlakies-style rerun protocol for same-order, isolated, OD, and NOD checks
69
+ - Luo taxonomy cause labels such as `network`, `time`, `io`, `resource_leak`,
70
+ `async_wait`, `randomness`, and `test_order_dependency`
71
+ - DeFlaker-style coverage and git diff suspicion signal
72
+ - Runtime integration with `rspec-covers` and `rspec-hermetic`
73
+ - JSON and JUnit formatters for CI ingestion
74
+ - Buildkite Ruby test collector tags when the collector is active
75
+ - Lightweight feature extraction, prediction, training, and evaluation commands
76
+
77
+ ### Configuration
78
+
79
+ Common options:
80
+
81
+ ```ruby
82
+ RSpec::FlakeClassifier.configure(config) do |flake|
83
+ flake.store = ".rspec_flake_store"
84
+ flake.auto_rerun = { failures: 3 }
85
+ flake.rspec_command = ["bundle", "exec", "rspec"]
86
+ flake.deflaker = true
87
+ flake.changed_lines_provider = -> { { "app/user.rb" => [10, 11] } }
88
+ flake.coverage_provider = ->(example) { example.metadata[:covered_lines] }
89
+ flake.probe_provider = ->(_example) { { files: ["tmp/cache"] } }
90
+ flake.skip_known_flakes = false
91
+ flake.run_sensitivity = false
92
+ flake.sensitivity_factors = %i[time randomness network]
93
+ end
94
+ ```
95
+
96
+ `auto_rerun` starts isolated subprocess reruns for failed examples. Keep it off
97
+ unless you are comfortable with the extra runtime cost in CI.
98
+
99
+ ### rspec-covers and rspec-hermetic
100
+
101
+ When all three gems are enabled, configure `rspec-covers` and `rspec-hermetic`
102
+ before `rspec-flake-classifier`. That lets the classifier observe their
103
+ after-example records from its outer RSpec hook.
104
+
105
+ ```ruby
106
+ require "rspec/covers"
107
+ require "rspec/hermetic"
108
+ require "rspec/flake/classifier"
109
+
110
+ RSpec.configure do |config|
111
+ RSpec::Covers.configure(config) do |covers|
112
+ covers.root = Dir.pwd
113
+ covers.production_paths = %w[app lib]
114
+ covers.risky = :report
115
+ end
116
+
117
+ RSpec::Hermetic.configure(config) do |hermetic|
118
+ hermetic.root_path = Dir.pwd
119
+ hermetic.probes = %i[filesystem resources]
120
+ hermetic.on_pollution = :report
121
+ end
122
+
123
+ RSpec::FlakeClassifier.configure(config) do |flake|
124
+ flake.store = ".rspec_flake_store"
125
+ flake.deflaker = true
126
+ end
127
+ end
128
+ ```
129
+
130
+ The classifier reads executed locations from `RSpec::Covers.reporter.results`
131
+ and filesystem or resource changes from `RSpec::Hermetic.runner.records`. It also
132
+ accepts compatible third-party adapters and explicit metadata such as
133
+ `:flake_classifier_coverage`, `:flake_classifier_files`,
134
+ `:flake_classifier_resources`, and `:flake_classifier_sockets`.
135
+
136
+ ### CI output
137
+
138
+ Require the formatter explicitly when you want machine-readable output:
139
+
140
+ ```bash
141
+ bundle exec rspec \
142
+ --require rspec/flake/classifier/formatter \
143
+ --format progress \
144
+ --format RSpec::FlakeClassifier::Formatter \
145
+ --out tmp/rspec-flakes.json
146
+ ```
147
+
148
+ For JUnit XML:
149
+
150
+ ```bash
151
+ bundle exec rspec \
152
+ --require rspec/flake/classifier/formatter \
153
+ --format progress \
154
+ --format RSpec::FlakeClassifier::JUnitFormatter \
155
+ --out test-results/rspec/rspec.xml
156
+ ```
157
+
158
+ The JUnit formatter adds `flaky`, `flaky_labels`, `flake_signature`,
159
+ `buildkite.*`, and `circleci.*` metadata.
160
+
161
+ ### Buildkite
162
+
163
+ Use the official Buildkite Ruby test collector as usual:
164
+
165
+ ```ruby
166
+ require "buildkite/test_collector"
167
+
168
+ Buildkite::TestCollector.configure(hook: :rspec)
169
+ ```
170
+
171
+ When the collector is active, `rspec-flake-classifier` tags failed executions
172
+ with:
173
+
174
+ - `rspec_flake_classifier.flaky`
175
+ - `rspec_flake_classifier.labels`
176
+ - `rspec_flake_classifier.signature`
177
+
178
+ ### CircleCI
179
+
180
+ Generate JUnit XML and store the results directory:
181
+
182
+ ```yaml
183
+ version: 2.1
184
+
185
+ jobs:
186
+ test:
187
+ docker:
188
+ - image: cimg/ruby:3.3
189
+ steps:
190
+ - checkout
191
+ - run: bundle install
192
+ - run:
193
+ name: Run RSpec
194
+ command: |
195
+ bundle exec rspec \
196
+ --require rspec/flake/classifier/formatter \
197
+ --format progress \
198
+ --format RSpec::FlakeClassifier::JUnitFormatter \
199
+ --out test-results/rspec/rspec.xml
200
+ - store_test_results:
201
+ path: test-results
202
+ ```
203
+
204
+ ### CLI
205
+
206
+ Investigate a failed example:
207
+
208
+ ```bash
209
+ rspec-flake investigate 'spec/models/user_spec.rb[1:2]' --seed 1234 --prior 'spec/models/user_spec.rb[1:1]'
210
+ ```
211
+
212
+ Classify a message or existing store records:
213
+
214
+ ```bash
215
+ rspec-flake classify "Net::HTTP timed out" --json
216
+ rspec-flake classify --from-store --store .rspec_flake_store --json
217
+ ```
218
+
219
+ Export features, rank files, train weights, and evaluate results:
220
+
221
+ ```bash
222
+ rspec-flake features spec/models/user_spec.rb --json
223
+ rspec-flake predict spec/models/user_spec.rb spec/system/search_spec.rb --json
224
+ rspec-flake train tmp/flake_features.jsonl --out tmp/flake_weights.json --json
225
+ rspec-flake evaluate --predictions tmp/predictions.json --ground-truth tmp/ground_truth.json --json
226
+ ```
227
+
228
+ Run sensitivity checks or summarize the store:
229
+
230
+ ```bash
231
+ rspec-flake sensitivity 'spec/models/user_spec.rb[1:2]' --factor network --json
232
+ rspec-flake report --store .rspec_flake_store --json
233
+ ```
234
+
235
+ ### Labels
236
+
237
+ Labels are multi-value and confidence-scored. A single failure can receive more
238
+ than one label.
239
+
240
+ Common labels include:
241
+
242
+ - `test_order_dependency`
243
+ - `async_wait`
244
+ - `concurrency`
245
+ - `resource_leak`
246
+ - `network`
247
+ - `time`
248
+ - `io`
249
+ - `randomness`
250
+ - `floating_point`
251
+ - `unordered_collections`
252
+ - `infrastructure`
253
+ - `suspected_flaky_deflaker`
254
+ - `known_flaky`
255
+
256
+ ## Development
257
+
258
+ After checking out the repo, run:
259
+
260
+ ```bash
261
+ bin/setup
262
+ ```
263
+
264
+ Run the test suite:
265
+
266
+ ```bash
267
+ bundle exec rake spec
268
+ ```
269
+
270
+ Validate RBS signatures:
271
+
272
+ ```bash
273
+ bundle exec rbs validate
274
+ ```
275
+
276
+ Build the gem:
277
+
278
+ ```bash
279
+ bundle exec rake build
280
+ ```
281
+
282
+ Open an interactive console:
283
+
284
+ ```bash
285
+ bin/console
286
+ ```
287
+
288
+ To install this gem onto your local machine:
289
+
290
+ ```bash
291
+ bundle exec rake install
292
+ ```
293
+
294
+ ## Contributing
295
+
296
+ Bug reports and pull requests are welcome on GitHub at
297
+ https://github.com/ydah/rspec-flake-classifier.
298
+
299
+ ## License
300
+
301
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/rspec-flake ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rspec/flake/classifier/cli"
5
+
6
+ exit RSpec::FlakeClassifier::CLI.new(ARGV).run
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context"
4
+ require_relative "result"
5
+
6
+ module RSpec
7
+ module FlakeClassifier
8
+ module Classify
9
+ class Classifier
10
+ RECOMMENDATIONS = {
11
+ "test_order_dependency" => "Isolate shared state and inspect polluter or cleaner examples.",
12
+ "async_wait" => "Replace sleeps with observable waits or Capybara matchers.",
13
+ "concurrency" => "Synchronize shared state and make thread lifecycle explicit.",
14
+ "resource_leak" => "Close leaked resources in example cleanup hooks.",
15
+ "network" => "Stub external calls with WebMock/VCR and fail on real sockets.",
16
+ "time" => "Freeze time and cover timezone or date-boundary cases explicitly.",
17
+ "io" => "Use isolated temp paths and avoid filesystem order assumptions.",
18
+ "randomness" => "Fix srand or dependency seeds and avoid random production data in assertions.",
19
+ "floating_point" => "Assert with tolerance instead of exact float equality.",
20
+ "unordered_collections" => "Use match_array/contain_exactly or compare sets.",
21
+ "infrastructure" => "Check CI host limits, disk, memory, and scheduler pressure.",
22
+ "suspected_flaky_deflaker" => "Review recent changes first; this failure did not cover changed lines.",
23
+ "known_flaky" => "Inspect the stored classification and linked commits."
24
+ }.freeze
25
+
26
+ def classify(input = nil, **attributes)
27
+ context = build_context(input, attributes)
28
+ labels = detectors.each_with_object([]) do |detector, result|
29
+ detected = detector.call(context)
30
+ next unless detected
31
+
32
+ detected.is_a?(Array) ? result.concat(detected.compact) : result << detected
33
+ end
34
+ status = labels.empty? ? "unknown" : "flaky"
35
+
36
+ Result.new(status: status, labels: labels, evidence: labels.flat_map(&:evidence))
37
+ end
38
+
39
+ private
40
+
41
+ def build_context(input, attributes)
42
+ return input if input.is_a?(Context)
43
+
44
+ if input.respond_to?(:message)
45
+ attributes[:message] ||= input.message
46
+ attributes[:backtrace] ||= input.backtrace
47
+ end
48
+
49
+ Context.new(**attributes)
50
+ end
51
+
52
+ def detectors
53
+ [
54
+ method(:deflaker_suspect),
55
+ method(:order_dependency),
56
+ method(:sensitivity),
57
+ method(:async_wait),
58
+ method(:concurrency),
59
+ method(:resource_leak),
60
+ method(:network),
61
+ method(:time),
62
+ method(:io),
63
+ method(:randomness),
64
+ method(:floating_point),
65
+ method(:unordered_collections),
66
+ method(:infrastructure)
67
+ ]
68
+ end
69
+
70
+ def order_dependency(context)
71
+ return unless context.order_dependent
72
+
73
+ label("test_order_dependency", 0.95, ["rerun protocol marked this example as order-dependent"])
74
+ end
75
+
76
+ def deflaker_suspect(context)
77
+ result = metadata_value(context, :deflaker)
78
+ return unless result && boolean_value(result, "suspected")
79
+
80
+ label("suspected_flaky_deflaker", 0.9, [result.fetch("reason", "covered code did not intersect changed lines")])
81
+ end
82
+
83
+ def sensitivity(context)
84
+ result = metadata_value(context, :sensitivity)
85
+ factors = result && (result["factors"] || result[:factors])
86
+ return unless factors
87
+
88
+ factors.filter_map do |factor, factor_result|
89
+ next unless boolean_value(factor_result, "sensitive")
90
+
91
+ category = sensitivity_category(factor)
92
+ label(category, 0.9, ["#{factor} sensitivity run changed the pass/fail outcome"])
93
+ end
94
+ end
95
+
96
+ def async_wait(context)
97
+ evidence = []
98
+ evidence << "wait/sleep/capybara call appears in failure context" if context.text.match?(/sleep|wait|eventually|capybara|selenium/i)
99
+ evidence << "large runtime spread across reruns" if large_duration_spread?(context.durations)
100
+ return if evidence.empty?
101
+
102
+ label("async_wait", evidence.length == 2 ? 0.85 : 0.65, evidence)
103
+ end
104
+
105
+ def concurrency(context)
106
+ return unless context.text.match?(/thread|fiber|mutex|deadlock|race|concurrent|monitor/i)
107
+
108
+ label("concurrency", 0.8, ["threading or race-related signal found"])
109
+ end
110
+
111
+ def resource_leak(context)
112
+ evidence = []
113
+ evidence << "resource probe reported leaked resources" unless context.resource_list.empty?
114
+ evidence << "failure context mentions leaked or exhausted resources" if context.text.match?(/leak|too many open files|connection pool|fd|descriptor/i)
115
+ return if evidence.empty?
116
+
117
+ label("resource_leak", evidence.length == 2 ? 0.9 : 0.75, evidence)
118
+ end
119
+
120
+ def network(context)
121
+ evidence = []
122
+ evidence << "socket probe recorded real network use" unless context.socket_list.empty?
123
+ evidence << "network exception or HTTP stack appears in failure context" if context.text.match?(/webmock|vcr|net::http|socket|dns|econn|timeout|timed out|connection refused/i)
124
+ return if evidence.empty?
125
+
126
+ label("network", evidence.length == 2 ? 0.9 : 0.8, evidence)
127
+ end
128
+
129
+ def time(context)
130
+ return unless context.text.match?(/timecop|active_support::testing::timehelpers|timezone|time zone|tz|date|time\.now|today|midnight/i)
131
+
132
+ label("time", 0.8, ["time or timezone dependency appears in failure context"])
133
+ end
134
+
135
+ def io(context)
136
+ evidence = []
137
+ evidence << "filesystem probe recorded file activity" unless context.file_list.empty?
138
+ evidence << "filesystem exception appears in failure context" if context.text.match?(/errno::e|no such file|permission denied|file|directory|tmpdir|tmp/i)
139
+ return if evidence.empty?
140
+
141
+ label("io", evidence.length == 2 ? 0.9 : 0.75, evidence)
142
+ end
143
+
144
+ def randomness(context)
145
+ return unless context.text.match?(/srand|securerandom|random|faker|seed|shuffle|sample/i)
146
+
147
+ label("randomness", 0.75, ["randomness or seed signal appears in failure context"])
148
+ end
149
+
150
+ def floating_point(context)
151
+ numbers = context.text.scan(/[-+]?\d+\.\d+(?:e[-+]?\d+)?/i).map(&:to_f)
152
+ return if numbers.length < 2
153
+
154
+ close_pair = numbers.combination(2).any? { |left, right| close_float?(left, right) }
155
+ return unless close_pair
156
+
157
+ label("floating_point", 0.85, ["near-equal floating point values appear in failure diff"])
158
+ end
159
+
160
+ def unordered_collections(context)
161
+ arrays = context.text.scan(/\[([^\[\]]+)\]/m).map { |(body)| tokenize_array(body) }.reject(&:empty?)
162
+ return if arrays.length < 2
163
+
164
+ matching_set = arrays.combination(2).any? do |left, right|
165
+ left != right && left.sort == right.sort
166
+ end
167
+ return unless matching_set
168
+
169
+ label("unordered_collections", 0.85, ["array-like values contain the same elements in different order"])
170
+ end
171
+
172
+ def infrastructure(context)
173
+ return unless context.text.match?(/out of memory|no space left|ci|buildkite|circleci|runner|killed|process terminated/i)
174
+
175
+ label("infrastructure", 0.55, ["failure context points to CI or host resource pressure"])
176
+ end
177
+
178
+ def label(category, confidence, evidence)
179
+ Label.new(
180
+ category: category,
181
+ confidence: confidence,
182
+ evidence: evidence,
183
+ action: RECOMMENDATIONS.fetch(category)
184
+ )
185
+ end
186
+
187
+ def large_duration_spread?(durations)
188
+ return false if durations.length < 2
189
+
190
+ min, max = durations.minmax
191
+ min.positive? && (max / min) >= 2.0
192
+ end
193
+
194
+ def close_float?(left, right)
195
+ delta = (left - right).abs
196
+ return false if delta.zero?
197
+
198
+ scale = [left.abs, right.abs, 1.0].max
199
+ delta <= scale * 1e-6
200
+ end
201
+
202
+ def metadata_value(context, key)
203
+ metadata = context.metadata || {}
204
+ metadata[key] || metadata[key.to_s]
205
+ end
206
+
207
+ def boolean_value(hash, key)
208
+ return false unless hash.respond_to?(:fetch)
209
+
210
+ hash.fetch(key, hash.fetch(key.to_sym, false)) == true
211
+ end
212
+
213
+ def sensitivity_category(factor)
214
+ case factor.to_s
215
+ when "time" then "time"
216
+ when "randomness" then "randomness"
217
+ when "network" then "network"
218
+ else "infrastructure"
219
+ end
220
+ end
221
+
222
+ def tokenize_array(body)
223
+ body.split(",").map { |value| value.strip.gsub(/\A["']|["']\z/, "") }
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ module Classify
6
+ Context = Struct.new(
7
+ :message,
8
+ :backtrace,
9
+ :source,
10
+ :duration_samples,
11
+ :order_dependent,
12
+ :single_run_passed,
13
+ :resources,
14
+ :files,
15
+ :sockets,
16
+ :metadata,
17
+ keyword_init: true
18
+ ) do
19
+ def text
20
+ [message, *Array(backtrace), source].compact.join("\n")
21
+ end
22
+
23
+ def durations
24
+ Array(duration_samples).map(&:to_f)
25
+ end
26
+
27
+ def resource_list
28
+ Array(resources)
29
+ end
30
+
31
+ def file_list
32
+ Array(files)
33
+ end
34
+
35
+ def socket_list
36
+ Array(sockets)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ module Classify
6
+ Label = Struct.new(:category, :confidence, :evidence, :action, keyword_init: true) do
7
+ def to_h
8
+ {
9
+ "category" => category,
10
+ "confidence" => confidence,
11
+ "evidence" => evidence,
12
+ "action" => action
13
+ }
14
+ end
15
+ end
16
+
17
+ class Result
18
+ attr_reader :status, :labels, :evidence
19
+
20
+ def initialize(status:, labels:, evidence: [])
21
+ @status = status.to_s
22
+ @labels = labels.sort_by { |label| -label.confidence.to_f }
23
+ @evidence = evidence
24
+ end
25
+
26
+ def primary_label
27
+ labels.first
28
+ end
29
+
30
+ def flaky?
31
+ status == "flaky" || !labels.empty?
32
+ end
33
+
34
+ def to_h
35
+ {
36
+ "status" => status,
37
+ "labels" => labels.map(&:to_h),
38
+ "evidence" => evidence
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end