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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module RSpec
6
+ module FlakeClassifier
7
+ module Rerun
8
+ class BisectDependencySearch
9
+ BisectRunResult = Struct.new(:all_example_ids, :failed_example_ids, keyword_init: true)
10
+
11
+ def initialize(runner:)
12
+ @runner = runner
13
+ end
14
+
15
+ def find(example_id, prior_examples, seed: nil)
16
+ return [] if prior_examples.empty?
17
+
18
+ require_rspec_bisect!
19
+ result = minimizer(example_id, prior_examples, seed).find_minimal_repro
20
+ Array(result) - [example_id]
21
+ rescue LoadError, NameError, StandardError
22
+ nil
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :runner
28
+
29
+ def require_rspec_bisect!
30
+ require "rspec/core/bisect/example_minimizer"
31
+ end
32
+
33
+ def minimizer(example_id, prior_examples, seed)
34
+ RSpec::Core::Bisect::ExampleMinimizer.new(
35
+ ShellCommand.new,
36
+ RunnerAdapter.new(runner, example_id, prior_examples, seed),
37
+ NullNotifier.new
38
+ )
39
+ end
40
+
41
+ class ShellCommand
42
+ def original_cli_args
43
+ []
44
+ end
45
+
46
+ def repro_command_from(locations)
47
+ locations.join(" ")
48
+ end
49
+ end
50
+
51
+ class RunnerAdapter
52
+ def initialize(runner, example_id, prior_examples, seed)
53
+ @runner = runner
54
+ @example_id = example_id
55
+ @prior_examples = prior_examples
56
+ @seed = seed
57
+ end
58
+
59
+ def original_results
60
+ BisectRunResult.new(
61
+ all_example_ids: @prior_examples + [@example_id],
62
+ failed_example_ids: [@example_id]
63
+ )
64
+ end
65
+
66
+ def run(example_ids)
67
+ result = @runner.run(example_ids, seed: @seed)
68
+ BisectRunResult.new(
69
+ all_example_ids: Array(example_ids),
70
+ failed_example_ids: result.failed? ? [@example_id] : []
71
+ )
72
+ end
73
+ end
74
+
75
+ class NullNotifier
76
+ def publish(*); end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+ require_relative "result"
6
+
7
+ module RSpec
8
+ module FlakeClassifier
9
+ module Rerun
10
+ class IsolatedRunner
11
+ DISABLE_ENV = "RSPEC_FLAKE_CLASSIFIER_CHILD"
12
+
13
+ attr_reader :command
14
+
15
+ def initialize(command: nil)
16
+ @command = Array(command || default_command)
17
+ end
18
+
19
+ def run(example_ids, seed: nil, env: {}, extra_args: [])
20
+ ids = Array(example_ids)
21
+ args = command + ids
22
+ args += ["--seed", seed.to_s] if seed
23
+ args += Array(extra_args)
24
+ child_env = { DISABLE_ENV => "1" }.merge(stringify_env(env))
25
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+ stdout, stderr, process_status = Open3.capture3(child_env, *args)
27
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
28
+
29
+ Result.new(
30
+ example_ids: ids,
31
+ seed: seed,
32
+ status: process_status.success? ? "passed" : "failed",
33
+ exit_status: process_status.exitstatus,
34
+ stdout: stdout,
35
+ stderr: stderr,
36
+ duration: duration,
37
+ command: args
38
+ )
39
+ rescue SystemCallError => e
40
+ Result.new(
41
+ example_ids: ids,
42
+ seed: seed,
43
+ status: "failed",
44
+ exit_status: nil,
45
+ stdout: "",
46
+ stderr: e.message,
47
+ duration: 0.0,
48
+ command: args
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def default_command
55
+ return ENV.fetch("RSPEC_FLAKE_RSPEC_COMMAND").split(" ") if ENV["RSPEC_FLAKE_RSPEC_COMMAND"]
56
+ return ["bundle", "exec", "rspec"] if File.file?("Gemfile")
57
+
58
+ [RbConfig.ruby, "-S", "rspec"]
59
+ end
60
+
61
+ def stringify_env(env)
62
+ env.each_with_object({}) do |(key, value), result|
63
+ result[key.to_s] = value.to_s
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "isolated_runner"
4
+ require_relative "bisect_dependency_search"
5
+
6
+ module RSpec
7
+ module FlakeClassifier
8
+ module Rerun
9
+ class Protocol
10
+ attr_reader :runner, :same_order_runs
11
+
12
+ def initialize(runner: IsolatedRunner.new, same_order_runs: 3)
13
+ @runner = runner
14
+ @same_order_runs = same_order_runs
15
+ end
16
+
17
+ def investigate(example_id, seed: nil, prior_examples: [])
18
+ sequence = Array(prior_examples) + [example_id]
19
+ runs = run_same_order(sequence, seed)
20
+ return true_failure(example_id, runs) if runs.all?(&:failed?)
21
+
22
+ single = runner.run(example_id, seed: seed, extra_args: ["--order", "defined"])
23
+ runs << single
24
+
25
+ if !Array(prior_examples).empty? && runs.any?(&:failed?) && single.passed?
26
+ dependencies = find_dependency(example_id, prior_examples, seed)
27
+ return flaky(example_id, "od", runs, dependencies, "victim")
28
+ end
29
+
30
+ flaky(example_id, "nod", runs)
31
+ end
32
+
33
+ private
34
+
35
+ def run_same_order(example_ids, seed)
36
+ Array.new(same_order_runs) { runner.run(example_ids, seed: seed) }
37
+ end
38
+
39
+ def true_failure(example_id, runs)
40
+ Investigation.new(example_id: example_id, status: "true_failure", order_type: nil, runs: runs)
41
+ end
42
+
43
+ def flaky(example_id, order_type, runs, dependency_examples = [], dependency_role = nil)
44
+ Investigation.new(
45
+ example_id: example_id,
46
+ status: "flaky",
47
+ order_type: order_type,
48
+ runs: runs,
49
+ dependency_examples: dependency_examples,
50
+ dependency_role: dependency_role
51
+ )
52
+ end
53
+
54
+ def find_dependency(example_id, prior_examples, seed)
55
+ candidates = Array(prior_examples)
56
+ return [] if candidates.empty?
57
+
58
+ bisect_result = BisectDependencySearch.new(runner: runner).find(example_id, candidates, seed: seed)
59
+ return bisect_result if bisect_result
60
+
61
+ found = []
62
+ search = candidates
63
+
64
+ until search.empty?
65
+ half_size = [(search.length / 2.0).ceil, 1].max
66
+ left = search.first(half_size)
67
+ right = search.drop(half_size)
68
+ run = runner.run(left + [example_id], seed: seed)
69
+
70
+ if run.failed?
71
+ found = left
72
+ search = left.length == search.length ? [] : left
73
+ else
74
+ search = right
75
+ end
76
+ end
77
+
78
+ found
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ module Rerun
6
+ Result = Struct.new(
7
+ :example_ids,
8
+ :seed,
9
+ :status,
10
+ :exit_status,
11
+ :stdout,
12
+ :stderr,
13
+ :duration,
14
+ :command,
15
+ keyword_init: true
16
+ ) do
17
+ def passed?
18
+ status.to_s == "passed"
19
+ end
20
+
21
+ def failed?
22
+ status.to_s == "failed"
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ "example_ids" => Array(example_ids),
28
+ "seed" => seed,
29
+ "status" => status,
30
+ "exit_status" => exit_status,
31
+ "duration" => duration,
32
+ "command" => Array(command)
33
+ }
34
+ end
35
+ end
36
+
37
+ class Investigation
38
+ attr_reader :example_id, :status, :order_type, :runs, :dependency_examples,
39
+ :dependency_role
40
+
41
+ def initialize(example_id:, status:, order_type:, runs:, dependency_examples: [], dependency_role: nil)
42
+ @example_id = example_id
43
+ @status = status.to_s
44
+ @order_type = order_type&.to_s
45
+ @runs = runs
46
+ @dependency_examples = dependency_examples
47
+ @dependency_role = dependency_role&.to_s
48
+ end
49
+
50
+ def flaky?
51
+ status == "flaky"
52
+ end
53
+
54
+ def true_failure?
55
+ status == "true_failure"
56
+ end
57
+
58
+ def labels
59
+ labels = []
60
+ labels << "flaky" if flaky?
61
+ labels << "order-dependent" if order_type == "od"
62
+ labels << "non-order-dependent" if order_type == "nod"
63
+ labels << dependency_role if dependency_role
64
+ labels
65
+ end
66
+
67
+ def to_h
68
+ {
69
+ "example_id" => example_id,
70
+ "status" => status,
71
+ "order_type" => order_type,
72
+ "dependency_role" => dependency_role,
73
+ "labels" => labels,
74
+ "dependency_examples" => dependency_examples,
75
+ "polluter_candidates" => dependency_role == "victim" ? dependency_examples : [],
76
+ "runs" => runs.map(&:to_h)
77
+ }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ module RuntimeControls
6
+ module_function
7
+
8
+ def activate!
9
+ freeze_time if ENV["RSPEC_FLAKE_TIME_NOW"]
10
+ block_network if ENV["RSPEC_FLAKE_BLOCK_NETWORK"] == "1"
11
+ end
12
+
13
+ def freeze_time
14
+ timestamp = Integer(ENV.fetch("RSPEC_FLAKE_TIME_NOW"))
15
+ return if Time.singleton_class.method_defined?(:flake_classifier_original_now)
16
+
17
+ Time.singleton_class.class_eval do
18
+ alias_method :flake_classifier_original_now, :now
19
+
20
+ define_method(:now) do
21
+ at(timestamp).getlocal
22
+ end
23
+ end
24
+ rescue ArgumentError
25
+ nil
26
+ end
27
+
28
+ def block_network
29
+ block_webmock
30
+ require "socket"
31
+ patch_tcp_socket
32
+ rescue LoadError
33
+ nil
34
+ end
35
+
36
+ def block_webmock
37
+ require "webmock"
38
+ return unless defined?(::WebMock) && ::WebMock.respond_to?(:disable_net_connect!)
39
+
40
+ ::WebMock.disable_net_connect!
41
+ rescue LoadError
42
+ nil
43
+ end
44
+
45
+ def patch_tcp_socket
46
+ return if TCPSocket.singleton_class.method_defined?(:flake_classifier_original_open)
47
+
48
+ TCPSocket.singleton_class.class_eval do
49
+ alias_method :flake_classifier_original_open, :open
50
+ alias_method :flake_classifier_original_new, :new
51
+
52
+ define_method(:open) do |*|
53
+ raise IOError, "network disabled by rspec-flake-classifier"
54
+ end
55
+
56
+ define_method(:new) do |*|
57
+ raise IOError, "network disabled by rspec-flake-classifier"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ class Sensitivity
6
+ Result = Struct.new(:example_id, :factors, :runs, keyword_init: true) do
7
+ def to_h
8
+ {
9
+ "example_id" => example_id,
10
+ "factors" => factors,
11
+ "runs" => runs.map(&:to_h)
12
+ }
13
+ end
14
+ end
15
+
16
+ def initialize(runner:)
17
+ @runner = runner
18
+ end
19
+
20
+ def analyze(example_id, factors: %i[time randomness network], seed: nil)
21
+ runs = []
22
+ factor_results = {}
23
+
24
+ Array(factors).each do |factor|
25
+ case factor.to_sym
26
+ when :time
27
+ factor_results["time"] = run_time_variants(example_id, seed, runs)
28
+ when :randomness
29
+ factor_results["randomness"] = run_randomness_variants(example_id, seed, runs)
30
+ when :network
31
+ factor_results["network"] = run_network_variant(example_id, seed, runs)
32
+ end
33
+ end
34
+
35
+ Result.new(example_id: example_id, factors: factor_results, runs: runs)
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :runner
41
+
42
+ def run_time_variants(example_id, seed, runs)
43
+ variants = [
44
+ ["UTC", "1704067200"],
45
+ ["Pacific/Kiritimati", "1704067200"],
46
+ ["America/Los_Angeles", "1704153600"]
47
+ ]
48
+ statuses = variants.map do |tz, timestamp|
49
+ run = runner.run(example_id, seed: seed, env: { "TZ" => tz, "RSPEC_FLAKE_TIME_NOW" => timestamp })
50
+ runs << run
51
+ ["#{tz}@#{timestamp}", run.status]
52
+ end
53
+ factor_result(statuses)
54
+ end
55
+
56
+ def run_randomness_variants(example_id, seed, runs)
57
+ seeds = [seed || 12_345, 54_321].uniq
58
+ statuses = seeds.map do |variant_seed|
59
+ run = runner.run(example_id, seed: variant_seed)
60
+ runs << run
61
+ [variant_seed.to_s, run.status]
62
+ end
63
+ factor_result(statuses)
64
+ end
65
+
66
+ def run_network_variant(example_id, seed, runs)
67
+ allowed = runner.run(example_id, seed: seed)
68
+ blocked = runner.run(example_id, seed: seed, env: { "RSPEC_FLAKE_BLOCK_NETWORK" => "1" })
69
+ runs.concat([allowed, blocked])
70
+ factor_result([["allowed", allowed.status], ["blocked", blocked.status]])
71
+ end
72
+
73
+ def factor_result(statuses)
74
+ unique_statuses = statuses.map(&:last).uniq
75
+ {
76
+ "sensitive" => unique_statuses.length > 1,
77
+ "observations" => statuses.to_h
78
+ }
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RSpec
6
+ module FlakeClassifier
7
+ class Signature
8
+ PLACEHOLDERS = {
9
+ object_address: "0xADDR",
10
+ tmp_path: "<TMP>",
11
+ line: ":LINE",
12
+ seed: "SEED",
13
+ timestamp: "<TIME>",
14
+ cwd: "<PWD>"
15
+ }.freeze
16
+
17
+ attr_reader :message, :backtrace, :normalized, :digest
18
+
19
+ def self.from_exception(exception)
20
+ new(
21
+ message: exception.message,
22
+ backtrace: Array(exception.backtrace)
23
+ )
24
+ end
25
+
26
+ def initialize(message:, backtrace: [])
27
+ @message = message.to_s
28
+ @backtrace = Array(backtrace).map(&:to_s)
29
+ @normalized = normalize([@message, *@backtrace].join("\n"))
30
+ @digest = Digest::SHA256.hexdigest(@normalized)
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ "digest" => digest,
36
+ "message" => message,
37
+ "normalized" => normalized,
38
+ "backtrace" => backtrace
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def normalize(text)
45
+ normalized = text.dup
46
+ normalized = normalized.gsub(Dir.pwd, PLACEHOLDERS.fetch(:cwd)) if Dir.pwd && !Dir.pwd.empty?
47
+ normalized = normalized.gsub(%r{#<([^>]+):0x[0-9a-f]+>}i, '#<\1:0xADDR>')
48
+ normalized = normalized.gsub(/0x[0-9a-f]+/i, PLACEHOLDERS.fetch(:object_address))
49
+ normalized = normalized.gsub(%r{(?:/private)?/var/folders/[^\s:]+}, PLACEHOLDERS.fetch(:tmp_path))
50
+ normalized = normalized.gsub(%r{(?:/private)?/tmp/[^\s:]+}, PLACEHOLDERS.fetch(:tmp_path))
51
+ normalized = normalized.gsub(%r{:[0-9]+(?::in `[^`]+')?}, PLACEHOLDERS.fetch(:line))
52
+ normalized = normalized.gsub(/\bseed(?:ed)?(?:\s+with)?\s+[0-9]+\b/i, "seed #{PLACEHOLDERS.fetch(:seed)}")
53
+ normalized = normalized.gsub(/\b[0-9]{4}-[0-9]{2}-[0-9]{2}[T\s][0-9:.+-Z]+\b/, PLACEHOLDERS.fetch(:timestamp))
54
+ normalized = normalized.gsub(/\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b/, PLACEHOLDERS.fetch(:timestamp))
55
+ normalized.gsub(/\b[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?\b/, PLACEHOLDERS.fetch(:timestamp))
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module RSpec
8
+ module FlakeClassifier
9
+ module Store
10
+ class JSONLStore
11
+ FILENAME = "failures.jsonl"
12
+
13
+ attr_reader :path
14
+
15
+ def initialize(path)
16
+ @path = path.to_s
17
+ end
18
+
19
+ def record(signature, classification: nil, metadata: {})
20
+ current = find(signature.digest)
21
+ now = Time.now.utc.iso8601
22
+ entry = build_entry(signature, current, classification, metadata, now)
23
+
24
+ FileUtils.mkdir_p(path)
25
+ File.open(file_path, "a") { |file| file.puts(JSON.generate(entry)) }
26
+ entry
27
+ end
28
+
29
+ def update_classification(digest, classification:, metadata: {})
30
+ current = find(digest)
31
+ return nil unless current
32
+
33
+ classification_hash = classification_to_hash(classification)
34
+ labels = labels_from(classification_hash)
35
+ labels = Array(current.fetch("labels", [])) if labels.empty?
36
+ entry = current.merge(
37
+ "classification" => classification_hash,
38
+ "labels" => labels,
39
+ "metadata" => current.fetch("metadata", {}).merge(stringify_keys(metadata))
40
+ )
41
+ FileUtils.mkdir_p(path)
42
+ File.open(file_path, "a") { |file| file.puts(JSON.generate(entry)) }
43
+ entry
44
+ end
45
+
46
+ def find(digest)
47
+ all[digest]
48
+ end
49
+
50
+ def known_flake?(digest)
51
+ entry = find(digest)
52
+ return false unless entry
53
+
54
+ labels = Array(entry["labels"]).map(&:to_s)
55
+ entry.dig("classification", "status") == "flaky" || labels.any? { |label| label.to_s.include?("flaky") }
56
+ end
57
+
58
+ def all
59
+ return {} unless File.file?(file_path)
60
+
61
+ File.foreach(file_path).each_with_object({}) do |line, entries|
62
+ next if line.strip.empty?
63
+
64
+ entry = JSON.parse(line)
65
+ entries[entry.fetch("digest")] = entry
66
+ rescue JSON::ParserError
67
+ next
68
+ end
69
+ end
70
+
71
+ def entries
72
+ all.values.sort_by { |entry| entry.fetch("last_seen", "") }.reverse
73
+ end
74
+
75
+ private
76
+
77
+ def file_path
78
+ File.join(path, FILENAME)
79
+ end
80
+
81
+ def build_entry(signature, current, classification, metadata, now)
82
+ classification_hash = classification_to_hash(classification)
83
+ labels = labels_from(classification_hash)
84
+ labels = Array(current&.fetch("labels", [])) if labels.empty?
85
+
86
+ {
87
+ "digest" => signature.digest,
88
+ "normalized" => signature.normalized,
89
+ "first_seen" => current&.fetch("first_seen", nil) || now,
90
+ "last_seen" => now,
91
+ "occurrences" => current&.fetch("occurrences", 0).to_i + 1,
92
+ "classification" => classification_hash,
93
+ "labels" => labels,
94
+ "example_ids" => merge_values(current, "example_ids", metadata[:example_id]),
95
+ "commits" => merge_values(current, "commits", metadata[:commit]),
96
+ "metadata" => stringify_keys(metadata)
97
+ }
98
+ end
99
+
100
+ def classification_to_hash(classification)
101
+ return nil unless classification
102
+ return classification.to_h if classification.respond_to?(:to_h)
103
+
104
+ classification
105
+ end
106
+
107
+ def labels_from(classification_hash)
108
+ Array(classification_hash&.fetch("labels", nil)).map do |label|
109
+ if label.respond_to?(:fetch)
110
+ label.fetch("category", label.fetch(:category, label.to_s))
111
+ else
112
+ label.to_s
113
+ end
114
+ end
115
+ end
116
+
117
+ def merge_values(current, key, value)
118
+ values = Array(current&.fetch(key, []))
119
+ values << value if value
120
+ values.map(&:to_s).uniq
121
+ end
122
+
123
+ def stringify_keys(hash)
124
+ hash.each_with_object({}) do |(key, value), result|
125
+ result[key.to_s] = value
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
8
+
9
+ module Rspec
10
+ module Flake
11
+ Classifier = ::RSpec::FlakeClassifier unless const_defined?(:Classifier, false)
12
+ end
13
+ end