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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/Rakefile +8 -0
- data/exe/rspec-flake +6 -0
- data/lib/rspec/flake/classifier/classify/classifier.rb +228 -0
- data/lib/rspec/flake/classifier/classify/context.rb +41 -0
- data/lib/rspec/flake/classifier/classify/result.rb +44 -0
- data/lib/rspec/flake/classifier/cli.rb +298 -0
- data/lib/rspec/flake/classifier/configuration.rb +40 -0
- data/lib/rspec/flake/classifier/coverage_snapshot.rb +89 -0
- data/lib/rspec/flake/classifier/deflaker.rb +102 -0
- data/lib/rspec/flake/classifier/evaluation.rb +127 -0
- data/lib/rspec/flake/classifier/example_history.rb +24 -0
- data/lib/rspec/flake/classifier/features.rb +42 -0
- data/lib/rspec/flake/classifier/formatter.rb +194 -0
- data/lib/rspec/flake/classifier/integrations.rb +247 -0
- data/lib/rspec/flake/classifier/predictor.rb +144 -0
- data/lib/rspec/flake/classifier/probe_evidence.rb +77 -0
- data/lib/rspec/flake/classifier/rerun/bisect_dependency_search.rb +81 -0
- data/lib/rspec/flake/classifier/rerun/isolated_runner.rb +69 -0
- data/lib/rspec/flake/classifier/rerun/protocol.rb +83 -0
- data/lib/rspec/flake/classifier/rerun/result.rb +82 -0
- data/lib/rspec/flake/classifier/runtime_controls.rb +63 -0
- data/lib/rspec/flake/classifier/sensitivity.rb +82 -0
- data/lib/rspec/flake/classifier/signature.rb +59 -0
- data/lib/rspec/flake/classifier/store/jsonl_store.rb +131 -0
- data/lib/rspec/flake/classifier/version.rb +13 -0
- data/lib/rspec/flake/classifier.rb +285 -0
- data/sig/rspec/flake/classifier.rbs +176 -0
- metadata +135 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../classifier"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module FlakeClassifier
|
|
9
|
+
class CLI
|
|
10
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
11
|
+
@argv = argv.dup
|
|
12
|
+
@stdout = stdout
|
|
13
|
+
@stderr = stderr
|
|
14
|
+
@options = { store: Configuration::DEFAULT_STORE, format: "text" }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
command = argv.shift
|
|
19
|
+
return usage(1) unless command
|
|
20
|
+
|
|
21
|
+
case command
|
|
22
|
+
when "investigate" then investigate
|
|
23
|
+
when "classify" then classify
|
|
24
|
+
when "features" then features
|
|
25
|
+
when "predict" then predict
|
|
26
|
+
when "train" then train
|
|
27
|
+
when "evaluate" then evaluate
|
|
28
|
+
when "sensitivity" then sensitivity
|
|
29
|
+
when "report" then report
|
|
30
|
+
else
|
|
31
|
+
stderr.puts("Unknown command: #{command}")
|
|
32
|
+
usage(1)
|
|
33
|
+
end
|
|
34
|
+
rescue OptionParser::ParseError, Error => e
|
|
35
|
+
stderr.puts(e.message)
|
|
36
|
+
1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :argv, :stdout, :stderr, :options
|
|
42
|
+
|
|
43
|
+
def investigate
|
|
44
|
+
parser = OptionParser.new do |parser|
|
|
45
|
+
parser.banner = "Usage: rspec-flake investigate EXAMPLE_ID [options]"
|
|
46
|
+
parser.on("--seed SEED", Integer) { |value| options[:seed] = value }
|
|
47
|
+
parser.on("--attempts N", Integer) { |value| options[:attempts] = value }
|
|
48
|
+
parser.on("--prior EXAMPLE_ID") { |value| (options[:prior_examples] ||= []) << value }
|
|
49
|
+
parser.on("--prior-file PATH") { |value| options[:prior_file] = value }
|
|
50
|
+
parser.on("--store PATH") { |value| options[:store] = value }
|
|
51
|
+
parser.on("--json") { options[:format] = "json" }
|
|
52
|
+
end
|
|
53
|
+
parser.parse!(argv)
|
|
54
|
+
example_id = argv.shift || raise(Error, "EXAMPLE_ID is required")
|
|
55
|
+
|
|
56
|
+
configuration.store = options[:store]
|
|
57
|
+
configuration.same_order_runs = options[:attempts] if options[:attempts]
|
|
58
|
+
result = RSpec::FlakeClassifier.investigate(
|
|
59
|
+
example_id,
|
|
60
|
+
seed: options[:seed],
|
|
61
|
+
prior_examples: prior_examples
|
|
62
|
+
)
|
|
63
|
+
write_result(result.to_h)
|
|
64
|
+
0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def classify
|
|
68
|
+
parser = OptionParser.new do |parser|
|
|
69
|
+
parser.banner = "Usage: rspec-flake classify [MESSAGE] [options]"
|
|
70
|
+
parser.on("--from-store") { options[:from_store] = true }
|
|
71
|
+
parser.on("--store PATH") { |value| options[:store] = value }
|
|
72
|
+
parser.on("--json") { options[:format] = "json" }
|
|
73
|
+
end
|
|
74
|
+
parser.parse!(argv)
|
|
75
|
+
|
|
76
|
+
if options[:from_store]
|
|
77
|
+
classify_store
|
|
78
|
+
else
|
|
79
|
+
message = argv.join(" ")
|
|
80
|
+
raise Error, "MESSAGE is required unless --from-store is used" if message.empty?
|
|
81
|
+
|
|
82
|
+
write_result(RSpec::FlakeClassifier.classify(message: message).to_h)
|
|
83
|
+
end
|
|
84
|
+
0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def classify_store
|
|
88
|
+
store = Store::JSONLStore.new(options[:store])
|
|
89
|
+
entries = store.entries.map do |entry|
|
|
90
|
+
result = RSpec::FlakeClassifier.classify(message: entry.fetch("normalized", ""))
|
|
91
|
+
store.update_classification(
|
|
92
|
+
entry.fetch("digest"),
|
|
93
|
+
classification: result,
|
|
94
|
+
metadata: { classified_by: "rspec-flake classify" }
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
write_result("entries" => entries)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def report
|
|
101
|
+
parser = OptionParser.new do |parser|
|
|
102
|
+
parser.banner = "Usage: rspec-flake report [options]"
|
|
103
|
+
parser.on("--store PATH") { |value| options[:store] = value }
|
|
104
|
+
parser.on("--json") { options[:format] = "json" }
|
|
105
|
+
end
|
|
106
|
+
parser.parse!(argv)
|
|
107
|
+
entries = Store::JSONLStore.new(options[:store]).entries
|
|
108
|
+
write_result(report_hash(entries))
|
|
109
|
+
0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def features
|
|
113
|
+
parser = OptionParser.new do |parser|
|
|
114
|
+
parser.banner = "Usage: rspec-flake features FILE [options]"
|
|
115
|
+
parser.on("--duration SECONDS", Float) { |value| options[:duration] = value }
|
|
116
|
+
parser.on("--json") { options[:format] = "json" }
|
|
117
|
+
end
|
|
118
|
+
parser.parse!(argv)
|
|
119
|
+
file = argv.shift || raise(Error, "FILE is required")
|
|
120
|
+
write_result(RSpec::FlakeClassifier::Features.new.extract(file: file, duration: options[:duration]))
|
|
121
|
+
0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def sensitivity
|
|
125
|
+
parser = OptionParser.new do |parser|
|
|
126
|
+
parser.banner = "Usage: rspec-flake sensitivity EXAMPLE_ID [options]"
|
|
127
|
+
parser.on("--seed SEED", Integer) { |value| options[:seed] = value }
|
|
128
|
+
parser.on("--factor FACTOR") { |value| (options[:factors] ||= []) << value.to_sym }
|
|
129
|
+
parser.on("--json") { options[:format] = "json" }
|
|
130
|
+
end
|
|
131
|
+
parser.parse!(argv)
|
|
132
|
+
example_id = argv.shift || raise(Error, "EXAMPLE_ID is required")
|
|
133
|
+
factors = options[:factors] || configuration.sensitivity_factors
|
|
134
|
+
write_result(RSpec::FlakeClassifier.sensitivity(example_id, factors: factors, seed: options[:seed]).to_h)
|
|
135
|
+
0
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def predict
|
|
139
|
+
parser = OptionParser.new do |parser|
|
|
140
|
+
parser.banner = "Usage: rspec-flake predict FILE... [options]"
|
|
141
|
+
parser.on("--weights PATH") { |value| options[:weights] = value }
|
|
142
|
+
parser.on("--json") { options[:format] = "json" }
|
|
143
|
+
end
|
|
144
|
+
parser.parse!(argv)
|
|
145
|
+
raise Error, "at least one FILE is required" if argv.empty?
|
|
146
|
+
|
|
147
|
+
extractor = RSpec::FlakeClassifier::Features.new
|
|
148
|
+
features = argv.map { |file| extractor.extract(file: file) }
|
|
149
|
+
write_result("predictions" => predictor.rank(features))
|
|
150
|
+
0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def train
|
|
154
|
+
parser = OptionParser.new do |parser|
|
|
155
|
+
parser.banner = "Usage: rspec-flake train FEATURES_JSON_OR_JSONL [options]"
|
|
156
|
+
parser.on("--out PATH") { |value| options[:out] = value }
|
|
157
|
+
parser.on("--json") { options[:format] = "json" }
|
|
158
|
+
end
|
|
159
|
+
parser.parse!(argv)
|
|
160
|
+
file = argv.shift || raise(Error, "FEATURES_JSON_OR_JSONL is required")
|
|
161
|
+
model = RSpec::FlakeClassifier::Predictor.train(read_records(file))
|
|
162
|
+
File.write(options[:out], JSON.pretty_generate(model.to_h)) if options[:out]
|
|
163
|
+
write_result(model.to_h)
|
|
164
|
+
0
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def evaluate
|
|
168
|
+
parser = OptionParser.new do |parser|
|
|
169
|
+
parser.banner = "Usage: rspec-flake evaluate [options]"
|
|
170
|
+
parser.on("--predictions PATH") { |value| options[:predictions] = value }
|
|
171
|
+
parser.on("--ground-truth PATH") { |value| options[:ground_truth] = value }
|
|
172
|
+
parser.on("--deflaker PATH") { |value| options[:deflaker_records] = value }
|
|
173
|
+
parser.on("--signatures PATH") { |value| options[:signature_records] = value }
|
|
174
|
+
parser.on("--idflakies PATH") { |value| options[:idflakies_records] = value }
|
|
175
|
+
parser.on("--json") { options[:format] = "json" }
|
|
176
|
+
end
|
|
177
|
+
parser.parse!(argv)
|
|
178
|
+
result = evaluation_result
|
|
179
|
+
raise Error, "no evaluation inputs provided" if result.empty?
|
|
180
|
+
|
|
181
|
+
write_result(result)
|
|
182
|
+
0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def report_hash(entries)
|
|
186
|
+
labels = Hash.new(0)
|
|
187
|
+
entries.each do |entry|
|
|
188
|
+
Array(entry["labels"]).each { |label| labels[label] += entry.fetch("occurrences", 1).to_i }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
"total_signatures" => entries.length,
|
|
193
|
+
"total_occurrences" => entries.sum { |entry| entry.fetch("occurrences", 1).to_i },
|
|
194
|
+
"labels" => labels.sort.to_h,
|
|
195
|
+
"entries" => entries
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def write_result(hash)
|
|
200
|
+
if options[:format] == "json"
|
|
201
|
+
stdout.puts(JSON.pretty_generate(hash))
|
|
202
|
+
else
|
|
203
|
+
stdout.puts(render_text(hash))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def render_text(hash)
|
|
208
|
+
return render_entries(hash.fetch("entries")) if hash.key?("entries")
|
|
209
|
+
|
|
210
|
+
hash.map { |key, value| "#{key}: #{value.inspect}" }.join("\n")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def render_entries(entries)
|
|
214
|
+
return "No flake signatures recorded." if entries.empty?
|
|
215
|
+
|
|
216
|
+
entries.map do |entry|
|
|
217
|
+
labels = Array(entry["labels"]).join(", ")
|
|
218
|
+
"#{entry["digest"]} occurrences=#{entry["occurrences"]} labels=#{labels}"
|
|
219
|
+
end.join("\n")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def usage(code)
|
|
223
|
+
stdout.puts(<<~TEXT)
|
|
224
|
+
Usage:
|
|
225
|
+
rspec-flake investigate EXAMPLE_ID [--seed SEED] [--attempts N] [--json]
|
|
226
|
+
rspec-flake classify MESSAGE [--json]
|
|
227
|
+
rspec-flake classify --from-store [--store PATH] [--json]
|
|
228
|
+
rspec-flake features FILE [--duration SECONDS] [--json]
|
|
229
|
+
rspec-flake predict FILE... [--weights PATH] [--json]
|
|
230
|
+
rspec-flake train FEATURES_JSON_OR_JSONL [--out PATH] [--json]
|
|
231
|
+
rspec-flake evaluate [--predictions PATH --ground-truth PATH] [--deflaker PATH] [--signatures PATH] [--idflakies PATH] [--json]
|
|
232
|
+
rspec-flake sensitivity EXAMPLE_ID [--seed SEED] [--factor FACTOR] [--json]
|
|
233
|
+
rspec-flake report [--store PATH] [--json]
|
|
234
|
+
TEXT
|
|
235
|
+
code
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def prior_examples
|
|
239
|
+
Array(options[:prior_examples]) + prior_examples_from_file
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def prior_examples_from_file
|
|
243
|
+
return [] unless options[:prior_file]
|
|
244
|
+
|
|
245
|
+
File.readlines(options[:prior_file], chomp: true).map(&:strip).reject(&:empty?)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def configuration
|
|
249
|
+
RSpec::FlakeClassifier.configuration
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def predictor
|
|
253
|
+
return RSpec::FlakeClassifier::Predictor.load(options[:weights]) if options[:weights]
|
|
254
|
+
|
|
255
|
+
RSpec::FlakeClassifier::Predictor.new
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def evaluation_result
|
|
259
|
+
evaluator = RSpec::FlakeClassifier::Evaluation.new
|
|
260
|
+
result = {}
|
|
261
|
+
if options[:predictions] && options[:ground_truth]
|
|
262
|
+
result["classification"] = evaluator.classification(
|
|
263
|
+
predictions: read_records(options[:predictions]),
|
|
264
|
+
ground_truth: read_records(options[:ground_truth])
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
result["deflaker"] = evaluator.deflaker(records: read_records(options[:deflaker_records])) if options[:deflaker_records]
|
|
268
|
+
result["signatures"] = evaluator.signatures(records: read_records(options[:signature_records])) if options[:signature_records]
|
|
269
|
+
result["idflakies"] = evaluator.idflakies(records: read_records(options[:idflakies_records])) if options[:idflakies_records]
|
|
270
|
+
result
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def read_records(path)
|
|
274
|
+
content = File.read(path)
|
|
275
|
+
stripped = content.strip
|
|
276
|
+
return [] if stripped.empty?
|
|
277
|
+
|
|
278
|
+
parsed = json_records(stripped)
|
|
279
|
+
return parsed if parsed
|
|
280
|
+
|
|
281
|
+
stripped.each_line.map { |line| JSON.parse(line) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def json_records(content)
|
|
285
|
+
parsed = JSON.parse(content)
|
|
286
|
+
return parsed if parsed.is_a?(Array)
|
|
287
|
+
return parsed.fetch("entries") if parsed.key?("entries")
|
|
288
|
+
return parsed.fetch("predictions") if parsed.key?("predictions")
|
|
289
|
+
return parsed.fetch("records") if parsed.key?("records")
|
|
290
|
+
return parsed.fetch("features") if parsed.key?("features")
|
|
291
|
+
|
|
292
|
+
[parsed]
|
|
293
|
+
rescue JSON::ParserError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_STORE = ".rspec_flake_store"
|
|
7
|
+
|
|
8
|
+
attr_accessor :store, :auto_rerun, :deflaker, :rspec_command,
|
|
9
|
+
:same_order_runs, :skip_known_flakes, :coverage_provider,
|
|
10
|
+
:changed_lines_provider, :probe_provider, :run_sensitivity,
|
|
11
|
+
:sensitivity_factors
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@store = DEFAULT_STORE
|
|
15
|
+
@auto_rerun = false
|
|
16
|
+
@deflaker = false
|
|
17
|
+
@rspec_command = nil
|
|
18
|
+
@same_order_runs = 3
|
|
19
|
+
@skip_known_flakes = false
|
|
20
|
+
@coverage_provider = nil
|
|
21
|
+
@changed_lines_provider = nil
|
|
22
|
+
@probe_provider = nil
|
|
23
|
+
@run_sensitivity = false
|
|
24
|
+
@sensitivity_factors = %i[time randomness network]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def auto_rerun_attempts
|
|
28
|
+
return same_order_runs unless auto_rerun.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
Integer(auto_rerun.fetch(:failures, same_order_runs))
|
|
31
|
+
rescue ArgumentError, TypeError
|
|
32
|
+
same_order_runs
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def store_path
|
|
36
|
+
store.to_s.empty? ? DEFAULT_STORE : store.to_s
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module FlakeClassifier
|
|
7
|
+
class CoverageSnapshot
|
|
8
|
+
def self.capture
|
|
9
|
+
return null unless Coverage.respond_to?(:running?) && Coverage.running?
|
|
10
|
+
return null unless Coverage.respond_to?(:peek_result)
|
|
11
|
+
|
|
12
|
+
new(Coverage.peek_result)
|
|
13
|
+
rescue RuntimeError
|
|
14
|
+
null
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.null
|
|
18
|
+
new({})
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(data)
|
|
22
|
+
@data = normalize(data)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delta
|
|
26
|
+
after = self.class.capture_snapshot
|
|
27
|
+
after.each_with_object({}) do |(file, counts), result|
|
|
28
|
+
changed = line_delta(Array(@data[file]), counts)
|
|
29
|
+
result[relative_path(file)] = changed unless changed.empty?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.capture_snapshot
|
|
34
|
+
return {} unless Coverage.respond_to?(:running?) && Coverage.running?
|
|
35
|
+
return {} unless Coverage.respond_to?(:peek_result)
|
|
36
|
+
|
|
37
|
+
normalize_static(Coverage.peek_result)
|
|
38
|
+
rescue RuntimeError
|
|
39
|
+
{}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.normalize_static(data)
|
|
43
|
+
data.each_with_object({}) do |(file, value), result|
|
|
44
|
+
result[file.to_s] = line_counts(value)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.line_counts(value)
|
|
49
|
+
return Array(value) if value.is_a?(Array)
|
|
50
|
+
return Array(value[:lines]) if value.is_a?(Hash) && value.key?(:lines)
|
|
51
|
+
return Array(value["lines"]) if value.is_a?(Hash) && value.key?("lines")
|
|
52
|
+
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def normalize(data)
|
|
59
|
+
self.class.normalize_static(data)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def line_delta(before, after)
|
|
63
|
+
after.each_with_index.each_with_object([]) do |(after_count, index), lines|
|
|
64
|
+
next unless count_positive?(after_count)
|
|
65
|
+
|
|
66
|
+
before_count = before[index]
|
|
67
|
+
lines << index + 1 if count_value(after_count) > count_value(before_count)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def count_positive?(count)
|
|
72
|
+
count_value(count).positive?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def count_value(count)
|
|
76
|
+
Integer(count || 0)
|
|
77
|
+
rescue ArgumentError, TypeError
|
|
78
|
+
0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def relative_path(file)
|
|
82
|
+
path = file.to_s
|
|
83
|
+
return path unless path.start_with?(Dir.pwd)
|
|
84
|
+
|
|
85
|
+
path.delete_prefix("#{Dir.pwd}/")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module FlakeClassifier
|
|
7
|
+
class Deflaker
|
|
8
|
+
Result = Struct.new(:suspected, :reason, :covered_lines, :changed_lines, keyword_init: true) do
|
|
9
|
+
def suspected?
|
|
10
|
+
suspected
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{
|
|
15
|
+
"suspected" => suspected,
|
|
16
|
+
"reason" => reason,
|
|
17
|
+
"covered_lines" => covered_lines,
|
|
18
|
+
"changed_lines" => changed_lines
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def suspect?(coverage:, changed_lines: nil, base: "HEAD")
|
|
24
|
+
covered = normalize_lines(coverage)
|
|
25
|
+
changed = normalize_lines(changed_lines || git_changed_lines(base))
|
|
26
|
+
intersection = intersect(covered, changed)
|
|
27
|
+
|
|
28
|
+
Result.new(
|
|
29
|
+
suspected: !covered.empty? && intersection.empty?,
|
|
30
|
+
reason: intersection.empty? ? "covered code did not intersect changed lines" : "covered changed lines",
|
|
31
|
+
covered_lines: covered,
|
|
32
|
+
changed_lines: changed
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def git_changed_lines(base = "HEAD")
|
|
37
|
+
stdout, _stderr, status = Open3.capture3("git", "diff", "--unified=0", base)
|
|
38
|
+
return {} unless status.success?
|
|
39
|
+
|
|
40
|
+
parse_diff(stdout)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_diff(diff)
|
|
44
|
+
changed = Hash.new { |hash, key| hash[key] = [] }
|
|
45
|
+
current_file = nil
|
|
46
|
+
|
|
47
|
+
diff.each_line do |line|
|
|
48
|
+
if (match = line.match(%r{\A\+\+\+ b/(.+)}))
|
|
49
|
+
current_file = match[1]
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
next unless current_file
|
|
54
|
+
|
|
55
|
+
match = line.match(/\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
|
56
|
+
next unless match
|
|
57
|
+
|
|
58
|
+
start = match[1].to_i
|
|
59
|
+
length = (match[2] || "1").to_i
|
|
60
|
+
next if length.zero?
|
|
61
|
+
|
|
62
|
+
changed[current_file].concat((start...(start + length)).to_a)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
changed.transform_values { |lines| lines.uniq.sort }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def normalize_lines(lines)
|
|
71
|
+
case lines
|
|
72
|
+
when Hash
|
|
73
|
+
lines.each_with_object({}) do |(file, file_lines), result|
|
|
74
|
+
normalized = Array(file_lines).map(&:to_i).reject(&:zero?).uniq.sort
|
|
75
|
+
result[file.to_s] = normalized unless normalized.empty?
|
|
76
|
+
end
|
|
77
|
+
when Array
|
|
78
|
+
lines.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |entry, result|
|
|
79
|
+
file, line = normalize_entry(entry)
|
|
80
|
+
result[file] << line if file && line.positive?
|
|
81
|
+
end.transform_values { |file_lines| file_lines.uniq.sort }
|
|
82
|
+
else
|
|
83
|
+
{}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_entry(entry)
|
|
88
|
+
return [entry[:file].to_s, entry[:line].to_i] if entry.is_a?(Hash) && entry.key?(:file)
|
|
89
|
+
return [entry["file"].to_s, entry["line"].to_i] if entry.is_a?(Hash) && entry.key?("file")
|
|
90
|
+
|
|
91
|
+
Array(entry).then { |file, line| [file.to_s, line.to_i] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def intersect(left, right)
|
|
95
|
+
left.each_with_object({}) do |(file, lines), result|
|
|
96
|
+
common = lines & Array(right[file])
|
|
97
|
+
result[file] = common unless common.empty?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
class Evaluation
|
|
6
|
+
def classification(predictions:, ground_truth:)
|
|
7
|
+
expected = index_by_id(ground_truth)
|
|
8
|
+
rows = Array(predictions).filter_map do |prediction|
|
|
9
|
+
id = record_id(prediction)
|
|
10
|
+
truth = Array(expected[id])
|
|
11
|
+
next if id.nil? || truth.empty?
|
|
12
|
+
|
|
13
|
+
predicted = labels_for(prediction)
|
|
14
|
+
{
|
|
15
|
+
"id" => id,
|
|
16
|
+
"truth" => truth,
|
|
17
|
+
"predicted" => predicted,
|
|
18
|
+
"top1" => truth.include?(predicted.first),
|
|
19
|
+
"top2" => !(truth & predicted.first(2)).empty?
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
metric_hash(rows)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def deflaker(records:)
|
|
26
|
+
flaky = Array(records).select { |record| flaky?(record) }
|
|
27
|
+
captured = flaky.select { |record| deflaker_suspected?(record) }
|
|
28
|
+
{
|
|
29
|
+
"flaky_count" => flaky.length,
|
|
30
|
+
"captured_count" => captured.length,
|
|
31
|
+
"recall" => ratio(captured.length, flaky.length)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def signatures(records:)
|
|
36
|
+
flaky = Array(records).select { |record| flaky?(record) }
|
|
37
|
+
signed = flaky.select { |record| signature_for(record) }
|
|
38
|
+
collision_groups = signed.group_by { |record| signature_for(record) }.values.count do |group|
|
|
39
|
+
group.flat_map { |record| labels_for(record) }.uniq.length > 1
|
|
40
|
+
end
|
|
41
|
+
{
|
|
42
|
+
"flaky_count" => flaky.length,
|
|
43
|
+
"signed_count" => signed.length,
|
|
44
|
+
"capture_rate" => ratio(signed.length, flaky.length),
|
|
45
|
+
"collision_groups" => collision_groups
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def idflakies(records:)
|
|
50
|
+
counts = Hash.new(0)
|
|
51
|
+
Array(records).each do |record|
|
|
52
|
+
order_type = record["order_type"] || record[:order_type] || record.dig("investigation", "order_type")
|
|
53
|
+
counts[order_type || "unknown"] += 1
|
|
54
|
+
end
|
|
55
|
+
total = counts.values.sum
|
|
56
|
+
{
|
|
57
|
+
"total" => total,
|
|
58
|
+
"counts" => counts.sort.to_h,
|
|
59
|
+
"od_ratio" => ratio(counts["od"], total),
|
|
60
|
+
"nod_ratio" => ratio(counts["nod"], total)
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def index_by_id(records)
|
|
67
|
+
Array(records).each_with_object({}) do |record, result|
|
|
68
|
+
id = record_id(record)
|
|
69
|
+
result[id] = labels_for(record) if id
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def metric_hash(rows)
|
|
74
|
+
{
|
|
75
|
+
"total" => rows.length,
|
|
76
|
+
"top1_accuracy" => ratio(rows.count { |row| row.fetch("top1") }, rows.length),
|
|
77
|
+
"top2_accuracy" => ratio(rows.count { |row| row.fetch("top2") }, rows.length),
|
|
78
|
+
"rows" => rows
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_id(record)
|
|
83
|
+
record["id"] || record[:id] || record["example_id"] || record[:example_id] ||
|
|
84
|
+
record["file"] || record[:file]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def labels_for(record)
|
|
88
|
+
labels = record["labels"] || record[:labels] ||
|
|
89
|
+
record.dig("classification", "labels") ||
|
|
90
|
+
record["categories"] || record[:categories] ||
|
|
91
|
+
record["truth"] || record[:truth] ||
|
|
92
|
+
record["label"] || record[:label]
|
|
93
|
+
Array(labels).map { |label| label_category(label) }.reject(&:empty?)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def label_category(label)
|
|
97
|
+
return label.fetch("category", label.fetch(:category, label.to_s)) if label.respond_to?(:fetch)
|
|
98
|
+
|
|
99
|
+
label.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def flaky?(record)
|
|
103
|
+
return true if record["flaky"] == true || record[:flaky] == true
|
|
104
|
+
return true if record["status"].to_s == "flaky" || record[:status].to_s == "flaky"
|
|
105
|
+
return true if record.dig("classification", "status").to_s == "flaky"
|
|
106
|
+
|
|
107
|
+
labels_for(record).any? { |label| label.include?("flaky") || label == "network" || label == "time" }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def deflaker_suspected?(record)
|
|
111
|
+
labels_for(record).include?("suspected_flaky_deflaker") ||
|
|
112
|
+
record.dig("metadata", "deflaker", "suspected") == true ||
|
|
113
|
+
record.dig("deflaker", "suspected") == true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def signature_for(record)
|
|
117
|
+
record["signature"] || record[:signature] || record["digest"] || record[:digest]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ratio(numerator, denominator)
|
|
121
|
+
return 0.0 if denominator.to_i.zero?
|
|
122
|
+
|
|
123
|
+
(numerator.to_f / denominator).round(4)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
class ExampleHistory
|
|
6
|
+
def initialize
|
|
7
|
+
@ids = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def prior_examples
|
|
11
|
+
@ids.dup
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record(example)
|
|
15
|
+
id = example.respond_to?(:id) ? example.id : nil
|
|
16
|
+
@ids << id if id && !@ids.include?(id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def clear
|
|
20
|
+
@ids.clear
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|