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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +1052 -0
- data/README.md +529 -0
- data/bin/jitcatch +16 -0
- data/lib/jitcatch/assessor.rb +502 -0
- data/lib/jitcatch/changes.rb +1084 -0
- data/lib/jitcatch/cli.rb +201 -0
- data/lib/jitcatch/diff_parser.rb +59 -0
- data/lib/jitcatch/factories.rb +164 -0
- data/lib/jitcatch/failure.rb +86 -0
- data/lib/jitcatch/framework.rb +110 -0
- data/lib/jitcatch/git_repo.rb +180 -0
- data/lib/jitcatch/inflections.rb +142 -0
- data/lib/jitcatch/minitest_parser.rb +110 -0
- data/lib/jitcatch/minitest_report.rb +54 -0
- data/lib/jitcatch/readers.rb +907 -0
- data/lib/jitcatch/render.rb +712 -0
- data/lib/jitcatch/routes.rb +288 -0
- data/lib/jitcatch/rspec.rb +9 -0
- data/lib/jitcatch/rspec_parser.rb +122 -0
- data/lib/jitcatch/runner.rb +905 -0
- data/lib/jitcatch/source_index.rb +1382 -0
- data/lib/jitcatch/template.rb +38 -0
- data/lib/jitcatch/version.rb +5 -0
- data/lib/jitcatch.rb +96 -0
- data/skills/jitcatch/SKILL.md +244 -0
- metadata +73 -0
|
@@ -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
|