jitcatch 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.
@@ -0,0 +1,502 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JitCatch
4
+ # Rule-based filter. It does not decide intended-versus-regression.
5
+ # That judgment needs the change intent and belongs to the agent.
6
+ class Assessor
7
+ ARITY = /wrong number of arguments|missing keywords?: |unknown keywords?: |struct size differs/
8
+ # Active Record's errors for a database it cannot reach. Each checkout
9
+ # uses its own numbered test database, so one that was never created
10
+ # fails on that side alone, which says nothing about the change.
11
+ NO_DATABASE = %w[ActiveRecord::NoDatabaseError ActiveRecord::ConnectionNotEstablished ActiveRecord::DatabaseConnectionError].freeze
12
+ # A database that `db:create` made has no tables until something loads
13
+ # the schema, which a test helper without `maintain_test_schema!` never
14
+ # does. Each adapter words the missing table its own way, and the
15
+ # SQLite adapter's own column lookup says "Could not find table".
16
+ NO_TABLE = /\APG::UndefinedTable: |\ASQLite3::SQLException: no such table: |\ACould not find table '|Table '[^']*' doesn't exist/
17
+
18
+ def self.no_table?(result)
19
+ result["error_class"] == "ActiveRecord::StatementInvalid" && NO_TABLE.match?(result["message"].to_s)
20
+ end
21
+
22
+ def self.apply(doc)
23
+ new(doc).apply
24
+ end
25
+
26
+ # The name a test writes to reach a method: its bare name, or
27
+ # `Owner.new` for a constructor.
28
+ def self.mention_of(qualified)
29
+ qualifier, sep, bare = SourceIndex.split_qualified(qualified)
30
+ constructor = (sep == "#" && bare == "initialize") || (sep == "." && bare == "new")
31
+ constructor ? "#{qualifier.split("::").last}.new" : bare
32
+ end
33
+
34
+ def initialize(doc)
35
+ @doc = doc
36
+ @methods = Array(doc["methods"])
37
+ @constants = Array(doc["constants"])
38
+ @callers = @methods.flat_map { |item| reached_by(item) }.uniq
39
+ @tests_dir = doc["tests_dir"]
40
+ end
41
+
42
+ def apply
43
+ tests = Array(@doc["tests"]).map { |test| strip(test) }
44
+ tests.each do |test|
45
+ if test["classification"] == "candidate"
46
+ method, pool, tied = related_method(test)
47
+ broken = broken_call(test, method)
48
+ call = broken&.frame
49
+ reason = discard_reason(test, call)
50
+ test["out_of_date"] = reason == "signature_change" ? out_of_date(broken) : nil
51
+ test["method_id"] = tied ? nil : method&.dig("id")
52
+ test["method_ids"] = pool.map { |item| item["id"] }.sort
53
+ test["disposition"] = reason ? "discarded" : "reported"
54
+ test["tags"] = tags_for(test, tied ? pool : [method].compact, reason, call)
55
+ test["tags"] = (test["tags"] + ["related:guess"]).sort if pool.size > 1
56
+ test["dynamic_call"] = method ? nil : dynamic_call(test)
57
+ test["tags"] = (test["tags"] + ["related:dynamic"]).sort if test["dynamic_call"]
58
+ else
59
+ test["method_id"] = nil
60
+ test["method_ids"] = []
61
+ test["dynamic_call"] = nil
62
+ test["out_of_date"] = nil
63
+ test["disposition"] = test["classification"]
64
+ test["tags"] = []
65
+ end
66
+ end
67
+ assign_candidate_ids(tests)
68
+ @doc.merge(
69
+ "tests" => tests,
70
+ "counts" => counts(tests),
71
+ "untested" => tests.count { |test| self.class.untested?(test, @doc["timeout_s"]) }
72
+ )
73
+ end
74
+
75
+ # A row whose side timed out, or found no database or no table, says
76
+ # nothing about the change, whatever its classification.
77
+ def self.untested?(test, timeout_s)
78
+ !untested_causes(test, timeout_s).empty?
79
+ end
80
+
81
+ UNTESTED_CAUSES = {
82
+ "timed out" => ->(result) { result["status"] == "timeout" },
83
+ "found no database" => ->(result) { NO_DATABASE.include?(result["error_class"]) },
84
+ "found no table" => ->(result) { no_table?(result) }
85
+ }.freeze
86
+
87
+ def self.untested_causes(test, timeout_s)
88
+ results = %w[base head].map { |side| test[side] || {} }
89
+ causes = UNTESTED_CAUSES.select { |_cause, seen| results.any?(&seen) }.keys
90
+ head_only_timeout?(test, timeout_s) ? causes - ["timed out"] : causes
91
+ end
92
+
93
+ # Base passed in at most half the time head ran out of, so a slow boot
94
+ # does not explain head's timeout: head may loop or wait forever, as a
95
+ # wait that checks a clock the test froze does.
96
+ def self.head_only_timeout?(test, timeout_s)
97
+ base = test["base"] || {}
98
+ seconds = base["seconds"]
99
+ test.dig("head", "status") == "timeout" && base["status"] == "pass" &&
100
+ seconds.is_a?(Numeric) && timeout_s.is_a?(Numeric) && seconds * 2 <= timeout_s
101
+ end
102
+
103
+ # A run where no row compared base with head, and the environment hid
104
+ # at least one row that might have, tested nothing, which must not read
105
+ # like a change with no finding. Its fix is outside the candidates. A
106
+ # run whose rows all failed on base with nothing hidden is the
107
+ # candidates' to fix, and each row's `base:` line says why.
108
+ def self.untested_run?(doc)
109
+ tests = Array(doc["tests"])
110
+ doc["untested"].to_i.positive? && tests.none? { |test| compared?(test, doc["timeout_s"]) }
111
+ end
112
+
113
+ # Base passed and neither side hit an untested cause.
114
+ def self.compared?(test, timeout_s)
115
+ test.dig("base", "status") == "pass" && !untested?(test, timeout_s)
116
+ end
117
+
118
+ private
119
+
120
+ def strip(test)
121
+ test.reject { |key, _| %w[method_id method_ids dynamic_call tags disposition candidate_id out_of_date].include?(key) }
122
+ end
123
+
124
+ def assign_candidate_ids(tests)
125
+ rows = tests.select { |test| test["classification"] == "candidate" }.sort_by { |test| test["id"] }
126
+ width = [2, rows.size.to_s.length].max
127
+ rows.each_with_index { |test, index| test["candidate_id"] = format("c%0#{width}d", index + 1) }
128
+ tests.each { |test| test["candidate_id"] ||= nil }
129
+ end
130
+
131
+ def counts(tests)
132
+ {
133
+ "generated" => tests.size,
134
+ "no_signal" => tests.count { |test| test["classification"] == "no_signal" },
135
+ "fail_on_base" => tests.count { |test| test["classification"] == "fail_on_base" },
136
+ "candidates" => tests.count { |test| test["classification"] == "candidate" },
137
+ "flaky" => tests.count { |test| test["classification"] == "flaky" },
138
+ "discarded" => tests.count { |test| test["disposition"] == "discarded" },
139
+ "reported" => tests.count { |test| test["disposition"] == "reported" }
140
+ }
141
+ end
142
+
143
+ def discard_reason(test, call)
144
+ head = test["head"] || {}
145
+ timed_out = head["status"] == "timeout" || head["error_class"] == "Timeout" || test.dig("base", "status") == "timeout"
146
+ return "timeout" if timed_out && !self.class.head_only_timeout?(test, @doc["timeout_s"])
147
+ return "load_error" if head["error_class"] == "LoadError"
148
+ return "no_database" if NO_DATABASE.include?(head["error_class"])
149
+ return "no_table" if self.class.no_table?(head)
150
+ # Only a call the test wrote is a claim the new signature voids. With
151
+ # no frame to say who called, the test is assumed to have.
152
+ return "signature_change" if call && (call.empty? || test_frame?(call))
153
+
154
+ nil
155
+ end
156
+
157
+ # A call the change broke: the frame that made it (an empty hash when the
158
+ # error kept none), the changed method or constant it called, and what
159
+ # changed about it.
160
+ Broken = Data.define(:frame, :subject, :change)
161
+
162
+ # A call to a removed name, or one whose arguments no longer fit. Nil
163
+ # when no call broke.
164
+ def broken_call(test, method)
165
+ error = raised(test)
166
+ symbol = missing_symbol(error)
167
+ if symbol && (subject = removed_subject(symbol))
168
+ return Broken.new(error.frames.first || {}, subject, "removed")
169
+ end
170
+ return nil unless arity_error?(error)
171
+
172
+ target = method_from_backtrace(test) || method
173
+ # A removed method leaves the inherited one, whose arity differs.
174
+ return changed_class_call(test, error) unless target && %w[changed removed].include?(target["signature"])
175
+
176
+ # The first frame is the callee's own line when Ruby defines it, so
177
+ # the caller is the next one.
178
+ frame = error.frames.first(2).find { |item| test_frame?(item) } || error.frames[1] || error.frames.first || {}
179
+ Broken.new(frame, target, target["signature"] == "removed" ? "removed" : "parameters")
180
+ end
181
+
182
+ # Ruby defines `new` of a Data or Struct class in C, so a member the
183
+ # change added or dropped fails the test's own call with no frame of the
184
+ # class: the first frame is the test's, and the test names the class.
185
+ def changed_class_call(test, error)
186
+ frame = error.frames.first
187
+ return nil unless frame && test_frame?(frame)
188
+
189
+ mentions = Array(test["mentions"])
190
+ constant = lowest(@constants.select { |item| item["change"] == "modified" && mentions.include?(item["bare_name"]) })
191
+ constant && Broken.new(frame, constant, "definition")
192
+ end
193
+
194
+ def out_of_date(broken)
195
+ frame = broken.frame
196
+ { "id" => broken.subject["id"], "name" => broken.subject["name"], "change" => broken.change,
197
+ "call" => frame["file"] && [frame["file"], frame["line"]].compact.join(":") }
198
+ end
199
+
200
+ Raised = Data.define(:error_class, :message, :frames)
201
+
202
+ # The error a call raised. assert_raises and RSpec's raise_error turn
203
+ # an error of another class into an assertion failure, so it is read
204
+ # back out of the failure message. Both parsers put the assertion's own
205
+ # line before the frames of the error it caught.
206
+ def raised(test)
207
+ head = test["head"] || {}
208
+ message = head["message"].to_s
209
+ case head["error_class"]
210
+ when "Minitest::Assertion"
211
+ if (match = message.match(/exception expected, not\nClass: <([\w:]+)>\nMessage: <(.*)>$/))
212
+ return Raised.new(match[1], undump(match[2]), frames(test).drop(1))
213
+ end
214
+ when "RSpec::Expectations::ExpectationNotMetError"
215
+ if (match = message.match(/\Aexpected .*, got #<([\w:]+): (.*)>$/))
216
+ return Raised.new(match[1], match[2], frames(test).drop(1))
217
+ end
218
+ end
219
+ Raised.new(head["error_class"], message, frames(test))
220
+ end
221
+
222
+ def undump(quoted)
223
+ quoted.undump
224
+ rescue RuntimeError
225
+ quoted
226
+ end
227
+
228
+ def arity_error?(error)
229
+ error.error_class == "ArgumentError" && error.message.match?(/\A(?:#{ARITY})/)
230
+ end
231
+
232
+ def test_frame?(frame)
233
+ !@tests_dir.nil? && frame["file"].to_s.start_with?("#{@tests_dir}/")
234
+ end
235
+
236
+ def caller_tag(call)
237
+ return nil if call.nil? || call.empty? || test_frame?(call)
238
+
239
+ changed = @methods.any? { |item|
240
+ %w[modified added].include?(item["change"]) && item["file"] == call["file"] &&
241
+ item["start_line"] && item["end_line"] && (item["start_line"]..item["end_line"]).cover?(call["line"])
242
+ }
243
+ edited = Array(@doc.dig("changed_lines", call["file"])).any? { |first, last| (first..last).cover?(call["line"]) }
244
+ "caller:#{changed || edited ? "changed" : "unchanged"}:#{call["file"]}:#{call["line"]}"
245
+ end
246
+
247
+ def tags_for(test, judged, reason, call)
248
+ head = test["head"] || {}
249
+ tags = []
250
+ tags << (reason ? "discard:#{reason}" : "report")
251
+ tags << caller_tag(call)
252
+ tags << "assertion" if head["status"] == "fail"
253
+ tags << "message_varies" if test["message_varies"]
254
+ tags << "head_only_timeout" if self.class.head_only_timeout?(test, @doc["timeout_s"])
255
+ if head["error_class"] && head["status"] != "fail"
256
+ tags << "error:#{head["error_class"]}"
257
+ end
258
+ tags.concat(touch_tags(test, judged))
259
+ tags.compact.uniq.sort
260
+ end
261
+
262
+ # A row with no pick judges every method of its pool, so its tags do not
263
+ # depend on which tied method has the lower id.
264
+ def touch_tags(test, judged)
265
+ mentions = Array(test["mentions"])
266
+ tags = []
267
+ changed_mentions = @methods.map { |item| mention(item) }
268
+ gem = origin_gem(test)
269
+ direct = gem.nil? && judged.any? { |method|
270
+ (mentioned?(method, mentions) && sent_to_named_receivers([method], mentions).any?) || frame_hits?(test, method)
271
+ }
272
+ tags << "touches:changed_method" if direct
273
+ tags << "touches:changed_dependency" if gem && Array(@doc["dependencies"]).any? { |dep| dep["gem"] == gem }
274
+ tags << "touches:changed_constant" if (mentions & @constants.map { |const| const["bare_name"] }).any?
275
+ # A bare name that a changed method shares counts as that method; one
276
+ # the test sends to a named class counts as that class's.
277
+ callers = (@callers - @methods.map { |item| item["name"] }).select { |name|
278
+ sent_to?(name, mentions) && (receivers_of(name, mentions).any? || !changed_mentions.include?(bare(name)))
279
+ }
280
+ tags << "touches:unchanged_caller" if callers.any?
281
+ tags
282
+ end
283
+
284
+ # The gem an error came from, when its first frame outside the candidate
285
+ # file is inside an installed gem. Such an error says the gem broke, not
286
+ # that a changed method returned a new value.
287
+ def origin_gem(test)
288
+ return nil unless test.dig("head", "status") == "error"
289
+
290
+ frame = frames(test).find { |item| !test_frame?(item) }
291
+ frame && frame["file"].to_s[%r{(?:\A|/)gems/([^/]+?)-\d[^/]*/}, 1]
292
+ end
293
+
294
+ # The pick and every changed method the same evidence points at. Two or
295
+ # more in the pool means the evidence cannot tell them apart, so the
296
+ # pick is a guess: a test that names only unchanged callers reaches
297
+ # every changed method below them.
298
+ def related_method(test)
299
+ symbol = missing_symbol(raised(test))
300
+ if symbol
301
+ hits = @methods.select { |item| item["bare_name"] == symbol }
302
+ return [lowest(hits), hits] if hits.any?
303
+ end
304
+ framed = @methods.select { |item| frame_hits?(test, item) }
305
+ # The innermost changed frame is where the error came from; the frames
306
+ # outside it only called it.
307
+ if framed.any?
308
+ innermost = method_from_backtrace(test)
309
+ return [innermost, [innermost]]
310
+ end
311
+
312
+ mentions = Array(test["mentions"])
313
+ mentioned = sent_to_named_receivers(@methods.select { |item| mentioned?(item, mentions) }, mentions)
314
+ # Almost every test builds its object with `.new`, so a changed
315
+ # constructor is the related method only when nothing else points.
316
+ constructors, named = mentioned.partition { |item| mention(item).end_with?(".new") }
317
+ return guess(named) if named.any?
318
+
319
+ via_caller = @methods.select { |item|
320
+ upstream = reached_by(item).map { |name| bare(name) }
321
+ (mentions & upstream).any?
322
+ }
323
+ sent = via_caller.select { |item| reached_by(item).any? { |name| sent_to?(name, mentions) } }
324
+ via_caller = sent unless sent.empty?
325
+ pool = via_caller.empty? ? constructors : via_caller
326
+ guess(pool, depth_below_entry(pool, mentions))
327
+ end
328
+
329
+ # The pick, the pool, and whether only the id ordered the best two.
330
+ # A tied pick still sets the tags, as it did when the id was shown.
331
+ def guess(items, depths = {})
332
+ ranked = items.sort_by { |item| guess_rank(item, items, depths) }
333
+ tied = ranked.size > 1 && guess_rank(ranked[0], items, depths)[0...-1] == guess_rank(ranked[1], items, depths)[0...-1]
334
+ [ranked.first, items, tied]
335
+ end
336
+
337
+ # A test that names the class it sends a method to (`Index.parse`,
338
+ # `described_class.parse`) calls that class's method of the name, not
339
+ # another class's. When no changed method of the name is on a class
340
+ # the test names, the call may reach one through a superclass or a
341
+ # module, so all of them stay, unless the named class's method is a
342
+ # known caller of a changed method: the call stops there.
343
+ def sent_to_named_receivers(items, mentions)
344
+ items.group_by { |item| bare(item["name"]) }.flat_map do |_name, group|
345
+ receivers = receivers_of(group.first["name"].to_s, mentions)
346
+ hits = group.select { |item| sent_to?(item["name"].to_s, mentions) }
347
+ hits.empty? && @callers.none? { |name| on_receiver?(name, receivers) } ? group : hits
348
+ end
349
+ end
350
+
351
+ # Whether the test calls the method: on a class it names when it names
352
+ # one for that method name, or by the bare name when it names none.
353
+ def sent_to?(qualified, mentions)
354
+ receivers = receivers_of(qualified, mentions)
355
+ receivers.empty? ? mentions.include?(bare(qualified)) : on_receiver?(qualified, receivers)
356
+ end
357
+
358
+ def on_receiver?(qualified, receivers)
359
+ qualifier, sep, name = SourceIndex.split_qualified(qualified)
360
+ receivers.any? { |owner, owner_sep, owner_name| owner_name == name && sep == owner_sep && (qualifier == owner || qualifier.end_with?("::#{owner}")) }
361
+ end
362
+
363
+ def receivers_of(qualified, mentions)
364
+ name = bare(qualified)
365
+ mentions.map { |mention| SourceIndex.split_qualified(mention) }
366
+ .select { |owner, _sep, bare_name| !owner.empty? && bare_name == name }
367
+ end
368
+
369
+ # With no related method, the call the test makes may still reach one
370
+ # through a receiver the source does not name. That is said, not guessed.
371
+ def dynamic_call(test)
372
+ mentions = Array(test["mentions"])
373
+ @methods.flat_map { |item| Array(item["dynamic_callers"]) }
374
+ .sort_by { |caller| [caller["from"], caller["name"]] }
375
+ .find { |caller| sent_to?(caller["from"], mentions) }
376
+ end
377
+
378
+ def method_from_backtrace(test)
379
+ frames(test).each do |frame|
380
+ hit = @methods.find { |item| frame_hits_method?(frame, item) }
381
+ return hit if hit
382
+ end
383
+ nil
384
+ end
385
+
386
+ def frame_hits?(test, method)
387
+ frames(test).any? { |frame| frame_hits_method?(frame, method) }
388
+ end
389
+
390
+ # The frames are the head run's, so only head's lines of a method count.
391
+ # A removed method has no lines on head.
392
+ def frame_hits_method?(frame, method)
393
+ return false unless frame["file"] == method["file"] && method["change"] != "removed"
394
+
395
+ (method["start_line"]..method["end_line"]).cover?(frame["line"])
396
+ end
397
+
398
+ def frames(test)
399
+ Array(test.dig("head", "backtrace"))
400
+ end
401
+
402
+ # The name a NameError says is missing. Another error, or an assertion,
403
+ # can quote the same words without any lookup having failed.
404
+ def missing_symbol(error)
405
+ return nil unless %w[NameError NoMethodError].include?(error.error_class)
406
+
407
+ message = error.message
408
+ if (match = message.match(/undefined method [`']([^`']+)[`']/))
409
+ match[1]
410
+ elsif (match = message.match(/undefined local variable or method [`']([^`']+)[`']/))
411
+ match[1]
412
+ elsif (match = message.match(/uninitialized constant ([\w:]+)/))
413
+ match[1]
414
+ end
415
+ end
416
+
417
+ def removed_subject(symbol)
418
+ removed_methods = @methods.select { |item| item["change"] == "removed" || item["signature"] == "removed" }
419
+ method = lowest(removed_methods.select { |item| item["bare_name"] == symbol })
420
+ return method if method
421
+
422
+ removed_constants = @constants.select { |item| item["change"] == "removed" }
423
+ lowest(removed_constants.select { |item| item["bare_name"] == symbol || item["name"] == symbol || symbol.end_with?("::#{item["bare_name"]}") })
424
+ end
425
+
426
+ # A catching test passed on the old code, so a method that already existed
427
+ # outranks one the diff only added. Among those, the method furthest
428
+ # downstream (the one other changed methods call into) is the usual source
429
+ # of the new value. An edit that only adds a call to an added method
430
+ # ranks with the added ones, since the value it routes is made below
431
+ # it. Between two that no changed method links, the one
432
+ # above more changed code is the likelier: a test that names a setup call
433
+ # and the call it checks reaches the change through the second.
434
+ #
435
+ # A test that enters high, as a request spec or a CLI test does,
436
+ # reaches the plumbing next to its entry point as well as the code its
437
+ # assertion checks further down. On a CLI test of this repository and
438
+ # a request spec of a Rails app, the method farthest from the entry
439
+ # point the test calls was the one the failure came from in both, and
440
+ # the lowest id in one, so depth ranks before the count below.
441
+ def guess_rank(item, items, depths)
442
+ others = items.reject { |other| other["id"] == item["id"] }
443
+ covered = others.count { |other| reached_by(item).include?(other["name"]) }
444
+ below = @methods.count { |other| reached_by(other).include?(item["name"]) }
445
+ [preference(item), -covered, -depths.fetch(item["id"], 0), -below, item["id"].to_s]
446
+ end
447
+
448
+ # The fewest calls between each item and an entry point the test
449
+ # names. A run file from before `caller_depths` existed has none.
450
+ def depth_below_entry(items, mentions)
451
+ items.to_h do |item|
452
+ depths = Hash(item["caller_depths"]).filter_map { |name, depth| depth if mentions.include?(bare(name)) }
453
+ [item["id"], depths.min || 0]
454
+ end
455
+ end
456
+
457
+ def preference(item)
458
+ return 2 if item["forwards_to_added"]
459
+
460
+ case item["change"]
461
+ when "modified", "affected", "moved" then 0
462
+ when "removed" then 1
463
+ else 2
464
+ end
465
+ end
466
+
467
+ def mention(item)
468
+ self.class.mention_of(item["name"].to_s)
469
+ end
470
+
471
+ # A test writes a constructor as `Box.new` or `A::Box.new`. The written
472
+ # path names this class when its segments end the class's name, so
473
+ # `A::Box.new` is not a mention of `B::Box#initialize`. A record builder
474
+ # (`Box.create!`) or a factory the change document lists for the
475
+ # constructor runs it too, and a save (`Box.create!`, `Box#save`) runs
476
+ # a callback the model declares.
477
+ def mentioned?(item, mentions)
478
+ name = mention(item)
479
+ calls = Array(item["builders"]) + Array(item["run_by"])
480
+ if name.end_with?(".new")
481
+ calls << "#{SourceIndex.split_qualified(item["name"].to_s).first}.new"
482
+ elsif mentions.include?(name)
483
+ return true
484
+ end
485
+ mentions.any? { |text| calls.any? { |call| SourceIndex.names_call?(text, call) } }
486
+ end
487
+
488
+ # Every method that reaches the item. A run file from before
489
+ # `reached_by` existed holds only the capped `reachable_from`.
490
+ def reached_by(item)
491
+ Array(item["reached_by"] || item["reachable_from"])
492
+ end
493
+
494
+ def lowest(items)
495
+ items.min_by { |item| item["id"].to_s }
496
+ end
497
+
498
+ def bare(qualified)
499
+ SourceIndex.split_qualified(qualified).last
500
+ end
501
+ end
502
+ end