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,1084 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JitCatch
4
+ # Builds the deterministic change document shared by `context` and `run`.
5
+ class Changes
6
+ MAX_BLOB = 200_000
7
+ CALLSITE_CAP = 8
8
+ REACHABLE_CAP = 8
9
+ TEST_CAP = 5
10
+ ENTRY_CAP = 12
11
+ UPSTREAM_DEPTH = 4
12
+ RECORD_BUILDERS = %w[create create! build].freeze
13
+ # A value the source does not type, whether or not its name names a model.
14
+ UNTYPED_RECEIVERS = %w[unknown named].freeze
15
+
16
+ # The classes and modules head defines, after `build`.
17
+ attr_reader :head_constants
18
+
19
+ # `readers: true` adds the code that reads each changed value without
20
+ # calling it by name, which only `context` prints.
21
+ def self.between(repo, base_ref, head_ref, coverage: nil, setup: nil, databases: [], readers: false)
22
+ new(repo, base_ref, head_ref, coverage: coverage, setup: setup, databases: databases, readers: readers).build
23
+ end
24
+
25
+ # `setup` is the command `run --setup` runs in each checkout, and
26
+ # `databases` the numbered test databases, base and head in turn.
27
+ def initialize(repo, base_ref, head_ref, coverage: nil, setup: nil, databases: [], readers: false)
28
+ @git = repo.is_a?(GitRepo) ? repo : GitRepo.new(repo)
29
+ @readers = readers
30
+ @setup = setup
31
+ @databases = databases
32
+ @base_ref = base_ref
33
+ @head_ref = head_ref
34
+ @coverage = coverage.nil? ? nil : CoverageFile.load(coverage)
35
+ @texts = {}
36
+ @parsed = {}
37
+ @test_names = {}
38
+ end
39
+
40
+ def build
41
+ base = @git.base_rev(@base_ref)
42
+ head = @git.rev(@head_ref)
43
+ files = DiffParser.name_status(@git.name_status(base, head))
44
+ diff = DiffParser.parse(@git.diff(base, head))
45
+ hunks = index_hunks(diff)
46
+ base_idx = index_ref(base, "base")
47
+ head_idx = index_ref(head, "head")
48
+ @head_constants = lookups(head_idx)[2].keys.reject(&:empty?).sort
49
+ methods = changed_methods(base_idx, head_idx, hunks)
50
+ constants = changed_constants(base_idx, head_idx, hunks, methods)
51
+ attach_affected(methods, constants, base_idx, head_idx)
52
+ assign_ids(methods, "m")
53
+ assign_ids(constants, "k")
54
+ link_constants(methods, constants)
55
+ head_edges = edges_for(head_idx)
56
+ base_edges = edges_for(base_idx)
57
+ decorate_calls(methods, head_edges, base_edges)
58
+ mark_forwarders(methods, head_edges, hunks)
59
+ decorate_dynamic_callers(methods, head_idx, base_idx)
60
+ factories = factories_of(head_idx)
61
+ builders = builders_for(methods, head_idx, factories)
62
+ decorate_callbacks(methods, base_idx, head_idx, factories, head_edges, base_edges)
63
+ decorate_tests(methods, base_idx, head_idx, builders)
64
+ entrypoints = entrypoints_for(methods, head_idx, base_idx)
65
+ readers = @readers ? implicit_readers(base_idx, head_idx, diff, methods, constants, base, head) : nil
66
+
67
+ {
68
+ "version" => VERSION,
69
+ "base" => base,
70
+ "head" => head,
71
+ "existing_tests_via" => @coverage ? "coverage" : "grep",
72
+ "warnings" => warnings(base, head) + schema_warnings(head, files) + computed_path_warnings(methods),
73
+ "intent" => intent_lines(base, head),
74
+ "files" => files,
75
+ "dependencies" => dependencies(base, head),
76
+ "parse_errors" => (base_idx["errors"] + head_idx["errors"]).sort_by { |err| [err["ref"], err["file"], err["message"]] },
77
+ "constants" => constants.map { |item| constant_hash(item) },
78
+ "methods" => methods.map { |item| method_hash(item) },
79
+ "changed_lines" => changed_lines(hunks),
80
+ "entrypoints" => entrypoints,
81
+ "factories_skipped" => factories.select(&:reason).map { |factory| { "file" => factory.file, "line" => factory.line, "name" => factory.name, "reason" => factory.reason } }
82
+ }.merge(readers ? { "readers" => readers } : {})
83
+ end
84
+
85
+ private
86
+
87
+ def warnings(base, head)
88
+ return ["base and head are the same commit, so the diff is empty. Pass the parent of the change as BASE."] if base == head
89
+
90
+ divergence = @git.divergence(base, head)
91
+ return [] unless divergence
92
+
93
+ count = divergence["base_only"]
94
+ commits = count == 1 ? "1 commit" : "#{count} commits"
95
+ fix = if divergence["merge_base"]
96
+ "Pass `git merge-base BASE HEAD` (#{divergence["merge_base"][0, 12]}) as BASE to review head's commits alone."
97
+ else
98
+ "The two revisions share no history."
99
+ end
100
+ ["base is not an ancestor of head: the diff also undoes the #{commits} only base has. #{fix}"]
101
+ end
102
+
103
+ # A Grape path built from code the source does not settle has no
104
+ # verb and path a request could be matched to.
105
+ def computed_path_warnings(methods)
106
+ methods.filter_map do |method|
107
+ node = method["new"] || method["old"]
108
+ next unless node.kind == "endpoint" && SourceIndex.computed_path?(node.bare_name)
109
+
110
+ "#{node.qualified} (#{node.file}:#{node.start_line}) builds its path from code jitcatch does not run, so no request a test sends is tied to it."
111
+ end
112
+ end
113
+
114
+ # Rails keeps a schema in db/schema.rb or db/structure.sql, and a
115
+ # second database's in db/<name>_schema.rb, beside db/<name>_migrate/.
116
+ SCHEMA_FILE = %r{\Adb/(?:\w+_)?(?:schema\.rb|structure\.sql)\z|\Adb/(?:\w+_)?migrate/}
117
+ TEST_HELPERS = %w[spec/rails_helper.rb spec/spec_helper.rb test/test_helper.rb].freeze
118
+
119
+ # A test database made once from one schema holds neither side's after
120
+ # a change to it, so a row can fail on a missing column and read as a
121
+ # regression. A helper that calls maintain_test_schema! loads the
122
+ # schema of the checkout it boots in; without one, only --setup can.
123
+ def schema_warnings(head, files)
124
+ changed = files.map { |file| file["file"] }.grep(SCHEMA_FILE).sort
125
+ return [] if changed.empty?
126
+
127
+ warning = "head changes the database schema (#{changed.join(", ")}), so each side's test database must hold that side's schema, " \
128
+ "or a row can fail on the database and not on the code."
129
+ return ["#{warning} `--setup` ran `#{@setup}` in each checkout before the candidates."] if @setup
130
+
131
+ helpers = TEST_HELPERS.select { |path| @git.show(head, path) }
132
+ return [warning] if helpers.empty? || helpers.any? { |path| @git.show(head, path).include?("maintain_test_schema!") }
133
+
134
+ databases = @databases.each_slice(2).map { |base, head_db| "base #{base}, head #{head_db}" }
135
+ into = databases.empty? ? "its own test database" : "its own test database (#{databases.join("; ")})"
136
+ ["#{warning} #{helpers.join(" and ")} #{helpers.one? ? "does" : "do"} not call `ActiveRecord::Migration.maintain_test_schema!`, so no candidate loads either schema: " \
137
+ "pass `--setup 'bin/rails db:create db:schema:load'` to `jitcatch run` to load each side's schema into #{into} before the candidates run."]
138
+ end
139
+
140
+ # Each spec line in a lockfile is four spaces, a name, and the version
141
+ # in parentheses. A gem locked for several platforms has several lines.
142
+ def dependencies(base, head)
143
+ old = locked_versions(base)
144
+ new = locked_versions(head)
145
+ (old.keys | new.keys).sort.filter_map do |gem|
146
+ next if old[gem] == new[gem]
147
+
148
+ { "gem" => gem, "old" => old[gem], "new" => new[gem] }
149
+ end
150
+ end
151
+
152
+ def locked_versions(sha)
153
+ @git.show(sha, "Gemfile.lock").to_s.scan(/^ (\S+) \(([^)]+)\)$/)
154
+ .group_by(&:first)
155
+ .transform_values { |pairs| pairs.map(&:last).uniq.sort.join(" ") }
156
+ end
157
+
158
+ # The head lines each Ruby file's hunks wrote, as inclusive ranges, so a
159
+ # call on an edited line counts as changed code even outside any method.
160
+ def changed_lines(hunks)
161
+ hunks.filter_map { |path, file|
162
+ ranges = file["hunks"].reject { |hunk| hunk["new_count"].zero? }.map { |hunk| [hunk["new_start"], hunk["new_start"] + hunk["new_count"] - 1] }
163
+ [path, ranges] if file["new_path"] && ranges.any?
164
+ }.sort.to_h
165
+ end
166
+
167
+ def index_hunks(files)
168
+ map = {}
169
+ files.each do |file|
170
+ path = file["new_path"] || file["old_path"]
171
+ next unless path&.end_with?(".rb")
172
+
173
+ map[path] = file
174
+ end
175
+ map
176
+ end
177
+
178
+ def index_ref(sha, label)
179
+ methods = []
180
+ constants = []
181
+ namespaces = []
182
+ superclasses = []
183
+ calls = []
184
+ private_constants = []
185
+ hidden_new = []
186
+ errors = []
187
+ sources = {}
188
+ indexes = {}
189
+ templates = {}
190
+ template_calls = []
191
+ listed = @git.source_blobs(sha).select { |blob| blob.size <= MAX_BLOB }
192
+ @texts.merge!(@git.read_blobs(listed.map(&:oid).uniq - @texts.keys))
193
+ listed, views = listed.partition { |blob| blob.path.end_with?(".rb") }
194
+ # A template defines no method, so its calls only add call sites, each
195
+ # made from the template itself.
196
+ views.each do |blob|
197
+ templates[blob.path] = @texts.fetch(blob.oid)
198
+ index = (@parsed[[blob.path, blob.oid]] ||= SourceIndex.parse(Template.as_ruby(templates[blob.path]), file: blob.path))
199
+ template_calls.concat(index.calls.map { |call| call.with(enclosing: call.enclosing || blob.path) }) unless index.parse_error
200
+ end
201
+ listed.each do |blob|
202
+ file = blob.path
203
+ sources[file] = @texts.fetch(blob.oid)
204
+ # A file both sides share is parsed once.
205
+ index = (@parsed[[file, blob.oid]] ||= SourceIndex.parse(sources[file], file: file))
206
+ indexes[file] = index
207
+ if index.parse_error
208
+ errors << { "ref" => label, "file" => file, "message" => index.parse_error }
209
+ next
210
+ end
211
+ methods.concat(index.methods)
212
+ constants.concat(index.constants)
213
+ namespaces.concat(index.namespaces)
214
+ superclasses.concat(index.superclasses)
215
+ calls.concat(index.calls)
216
+ private_constants.concat(index.private_constants)
217
+ hidden_new.concat(index.hidden_new)
218
+ end
219
+ methods = SourceIndex.hide_private_constants(methods, private_constants.uniq)
220
+ { "methods" => methods, "constants" => constants, "namespaces" => namespaces, "superclasses" => superclasses, "calls" => calls, "hidden_new" => hidden_new, "errors" => errors, "sources" => sources, "indexes" => indexes,
221
+ "templates" => templates, "template_calls" => template_calls }
222
+ end
223
+
224
+ def changed_methods(base_idx, head_idx, hunks)
225
+ old_map = index_by(base_idx["methods"])
226
+ new_map = index_by(head_idx["methods"])
227
+ found = []
228
+ (old_map.keys | new_map.keys).each do |key|
229
+ old_m = old_map[key]
230
+ new_m = new_map[key]
231
+ file = (new_m || old_m).file
232
+ file_hunks = hunks[file]
233
+ next if file_hunks.nil? || test_file?(file)
234
+
235
+ old_hit = old_m && DiffParser.overlaps?(old_m.start_line, old_m.end_line, file_hunks["hunks"], "old")
236
+ new_hit = new_m && DiffParser.overlaps?(new_m.start_line, new_m.end_line, file_hunks["hunks"], "new")
237
+ source_diff = norm(old_m&.source) != norm(new_m&.source)
238
+ params_diff = old_m&.params.to_s != new_m&.params.to_s
239
+ # A macro line holds several names, so a hunk on it says nothing about one name.
240
+ line_hit = (old_hit || new_hit) && (old_m || new_m).macro.nil?
241
+ visibility_diff = old_m && new_m && old_m.visibility != new_m.visibility
242
+ change = if old_m && new_m
243
+ next unless line_hit || source_diff || params_diff || visibility_diff
244
+
245
+ "modified"
246
+ elsif new_m && (new_hit || file_hunks["old_path"].nil?)
247
+ "added"
248
+ elsif old_m && (old_hit || file_hunks["new_path"].nil?)
249
+ "removed"
250
+ end
251
+ next unless change
252
+
253
+ found << {
254
+ "change" => change,
255
+ "old" => old_m,
256
+ "new" => new_m,
257
+ "via_constant" => nil
258
+ }
259
+ end
260
+ mark_moves(found, head_idx["methods"], hunks) { "moved" }
261
+ end
262
+
263
+ # A method that leaves one file while head defines the same name in
264
+ # another is moved, not removed: it still answers a call. The other side
265
+ # is read from every file, test files too, since a test double can move
266
+ # into test/, where no change of its own is listed. A file the diff
267
+ # touched is the likelier destination than an older copy elsewhere.
268
+ # The block names the change a move is, from the old and the new node.
269
+ def mark_moves(found, head_nodes, hunks)
270
+ by_name = head_nodes.group_by(&:qualified)
271
+ found.select { |item| item["change"] == "removed" && !item["namespace"] }.each do |item|
272
+ old_m = item["old"]
273
+ dest = Array(by_name[old_m.qualified]).reject { |node| node.file == old_m.file }
274
+ .min_by { |node| [hunks.key?(node.file) ? 0 : 1, node.file] }
275
+ next unless dest
276
+
277
+ found.reject! { |other| other["change"] == "added" && identity(other["new"]) == identity(dest) }
278
+ item.merge!("change" => yield(old_m, dest), "new" => dest, "moved_from" => old_m.file)
279
+ end
280
+ found
281
+ end
282
+
283
+ def changed_constants(base_idx, head_idx, hunks, _methods)
284
+ old_map = index_by(base_idx["constants"])
285
+ new_map = index_by(head_idx["constants"])
286
+ found = []
287
+ (old_map.keys | new_map.keys).each do |key|
288
+ old_c = old_map[key]
289
+ new_c = new_map[key]
290
+ file = (new_c || old_c).file
291
+ file_hunks = hunks[file]
292
+ next if file_hunks.nil? || test_file?(file)
293
+
294
+ old_hit = old_c && DiffParser.overlaps?(old_c.start_line, old_c.end_line, file_hunks["hunks"], "old")
295
+ new_hit = new_c && DiffParser.overlaps?(new_c.start_line, new_c.end_line, file_hunks["hunks"], "new")
296
+ source_diff = norm(old_c&.source) != norm(new_c&.source)
297
+ change = if old_c && new_c
298
+ next unless old_hit || new_hit || source_diff
299
+
300
+ "modified"
301
+ elsif new_c && (new_hit || file_hunks["old_path"].nil?)
302
+ "added"
303
+ elsif old_c && (old_hit || file_hunks["new_path"].nil?)
304
+ "removed"
305
+ end
306
+ next unless change
307
+
308
+ found << { "change" => change, "old" => old_c, "new" => new_c, "read_by" => [] }
309
+ end
310
+ # A constant that keeps its value in its new file is moved, and its
311
+ # readers see nothing new; one whose value changed on the way is modified.
312
+ mark_moves(found, head_idx["constants"], hunks) { |old_c, new_c| norm(value_of(old_c)) == norm(value_of(new_c)) ? "moved" : "modified" }
313
+ found + changed_namespaces(base_idx, head_idx, hunks)
314
+ end
315
+
316
+ def value_of(const)
317
+ const.source.sub(/A[^=]*=/, "").strip
318
+ end
319
+
320
+ # A class or module counts only when the other side defines it nowhere,
321
+ # so a file that reopens an existing namespace adds nothing. Its body
322
+ # changes show up as the methods and constants inside it.
323
+ def changed_namespaces(base_idx, head_idx, hunks)
324
+ old_names = base_idx["namespaces"].map(&:qualified)
325
+ new_names = head_idx["namespaces"].map(&:qualified)
326
+ removed = base_idx["namespaces"].reject { |node| new_names.include?(node.qualified) }
327
+ added = head_idx["namespaces"].reject { |node| old_names.include?(node.qualified) }
328
+ found = removed.uniq(&:qualified).filter_map { |node|
329
+ { "change" => "removed", "old" => node, "new" => nil, "read_by" => [], "namespace" => true } if hunks[node.file] && !test_file?(node.file)
330
+ }
331
+ found + added.uniq(&:qualified).filter_map { |node|
332
+ { "change" => "added", "old" => nil, "new" => node, "read_by" => [], "namespace" => true } if hunks[node.file] && !test_file?(node.file)
333
+ }
334
+ end
335
+
336
+ def attach_affected(methods, constants, base_idx, head_idx)
337
+ return if constants.empty?
338
+
339
+ known = methods.to_h { |item| [identity(item["new"] || item["old"]), item] }
340
+ constants.each do |const|
341
+ next if const["namespace"] || const["change"] == "moved"
342
+
343
+ bare = (const["new"] || const["old"]).bare_name
344
+ pattern = /\b#{Regexp.escape(bare)}\b/
345
+ head_idx["methods"].each do |method|
346
+ next if test_file?(method.file)
347
+ next unless method.source.match?(pattern)
348
+
349
+ key = identity(method)
350
+ next if known[key]
351
+
352
+ old_m = base_idx["methods"].find { |item| identity(item) == key }
353
+ next unless old_m
354
+
355
+ item = { "change" => "affected", "old" => old_m, "new" => method, "via_constant" => nil }
356
+ methods << item
357
+ known[key] = item
358
+ end
359
+ end
360
+ end
361
+
362
+ def link_constants(methods, constants)
363
+ constants.each do |const|
364
+ bare = (const["new"] || const["old"]).bare_name
365
+ pattern = /\b#{Regexp.escape(bare)}\b/
366
+ const["read_by"] = methods.filter_map { |method|
367
+ sources = [method["old"]&.source, method["new"]&.source].compact
368
+ method["id"] if sources.any? { |source| source.match?(pattern) }
369
+ }.uniq.sort
370
+ end
371
+ methods.each do |method|
372
+ next unless method["change"] == "affected"
373
+
374
+ related = constants.select { |const| const["read_by"].include?(method["id"]) }
375
+ method["via_constant"] = related.map { |const| const["id"] }.min
376
+ end
377
+ end
378
+
379
+ def decorate_calls(methods, head_edges, base_edges)
380
+ methods.each do |method|
381
+ node = method["new"] || method["old"]
382
+ edges = method["change"] == "removed" ? base_edges : head_edges
383
+ direct = edges.select { |edge| edge["to"] == node.qualified }
384
+ method["callsites"] = direct.first(CALLSITE_CAP).map { |edge|
385
+ { "file" => edge["file"], "line" => edge["line"], "enclosing" => edge["from"] }
386
+ }
387
+ method["callsites_omitted"] = [direct.size - CALLSITE_CAP, 0].max
388
+ method["reachable_from"] = upstream(node.qualified, edges)
389
+ # Every method that reaches this one, at any depth and with no cap,
390
+ # with the fewest calls between them. A test that enters high, as a
391
+ # request spec or a CLI test does, calls a method above the callers
392
+ # `upstream` keeps, so the assessor reads this set instead.
393
+ method["caller_depths"] = callers_by_depth(node.qualified, edges, nil)
394
+ end
395
+ end
396
+
397
+ # An edit whose every added line calls a method the diff added, and that
398
+ # removes no more lines than it adds, only routes a value from that
399
+ # method, as `net - volume` becoming `net - volume - coupon` does. The
400
+ # new value then comes from the added method, not from this one.
401
+ def mark_forwarders(methods, head_edges, hunks)
402
+ added = methods.select { |method| method["change"] == "added" }.map { |method| method["new"].qualified }
403
+ methods.each do |method|
404
+ method["forwards_to_added"] = method["change"] == "modified" && added.any? && forwards?(method["new"], added, head_edges, hunks)
405
+ end
406
+ end
407
+
408
+ def forwards?(node, added, head_edges, hunks)
409
+ lines = []
410
+ removed = 0
411
+ hunks.fetch(node.file, { "hunks" => [] })["hunks"].each do |hunk|
412
+ inside = (hunk["new_start"]...(hunk["new_start"] + hunk["new_count"])).select { |line| line.between?(node.start_line, node.end_line) }
413
+ # A pure deletion has no new lines; git puts it after new_start.
414
+ deletion = hunk["new_count"].zero? && hunk["new_start"] >= node.start_line && hunk["new_start"] < node.end_line
415
+ next if inside.empty? && !deletion
416
+
417
+ lines.concat(inside)
418
+ removed += hunk["old_count"]
419
+ end
420
+ calls = head_edges.filter_map { |edge| edge["line"] if edge["from"] == node.qualified && edge["file"] == node.file && added.include?(edge["to"]) }
421
+ lines.any? && removed <= lines.size && (lines - calls).empty?
422
+ end
423
+
424
+ # A method that sends the name of a changed method, or of one that
425
+ # reaches it, to a receiver the source does not name, as in
426
+ # `EMITTERS.fetch(format).render`, may reach it, but no edge says so.
427
+ # The router calls a controller action, and no code in the app sends
428
+ # its name, so `cipher.update` does not reach `ThingsController#update`.
429
+ def decorate_dynamic_callers(methods, head_idx, base_idx)
430
+ methods.each do |method|
431
+ node = method["new"] || method["old"]
432
+ idx = method["change"] == "removed" ? base_idx : head_idx
433
+ sent = [node.qualified, *method["reachable_from"]].reject { |name| controller_action?(name) }
434
+ names = sent.to_h { |name| [SourceIndex.split_qualified(name).last, name] }
435
+ callers = idx["calls"].filter_map do |call|
436
+ next unless UNTYPED_RECEIVERS.include?(call.receiver.kind) && names.key?(call.bare_name) && call.enclosing && !test_file?(call.file)
437
+ next if call.enclosing == node.qualified || method["reachable_from"].include?(call.enclosing)
438
+
439
+ { "from" => call.enclosing, "name" => call.bare_name }
440
+ end
441
+ method["dynamic_callers"] = callers.uniq.sort_by { |caller| [caller["from"], caller["name"]] }.first(REACHABLE_CAP)
442
+ end
443
+ end
444
+
445
+ def controller_action?(name)
446
+ owner, sep, = SourceIndex.split_qualified(name)
447
+ sep == "#" && owner.split("::").last.to_s.end_with?("Controller")
448
+ end
449
+
450
+ def decorate_tests(methods, base_idx, head_idx, builders)
451
+ test_sources = test_sources_for(base_idx, head_idx)
452
+ public_names = (base_idx["methods"] + head_idx["methods"]).filter_map { |node| [node.qualified, true] if node.visibility == "public" }.to_h
453
+ routes = requests = nil
454
+ methods.each do |method|
455
+ node = method["new"] || method["old"]
456
+ method["builders"] = constructor?(node) ? builders.fetch(node.qualifier) : []
457
+ method["existing_tests"] = if @coverage
458
+ coverage_hits(method)
459
+ else
460
+ # No test calls `initialize` by that name, so a file that writes
461
+ # the word only reads or names the method.
462
+ if constructor?(node)
463
+ merge_hits(call_hits(test_sources.keys, ["#{node.qualifier}.new", *method["builders"]], "builder", base_idx, head_idx), [])
464
+ elsif node.kind == "endpoint"
465
+ request_hits(test_sources, node.bare_name)
466
+ elsif controller_action?(node.qualified) && (routes ||= app_routes(head_idx)).drawn?(node.qualified)
467
+ route_hits(test_sources, node.qualified, requests ||= test_requests(test_sources, routes, base_idx, head_idx))
468
+ else
469
+ merge_hits(call_hits(test_sources.keys, method["run_by"], "callback", base_idx, head_idx), grep_hits(test_sources, node.bare_name))
470
+ end
471
+ end
472
+ method["entry_point_tests"] = if @coverage || !method["existing_tests"].empty?
473
+ []
474
+ else
475
+ entry_point_hits(test_sources, method["reachable_from"].select { |name| public_names.key?(name) })
476
+ end
477
+ end
478
+ end
479
+
480
+ # A file counts when it names the entry point's class as a value (not as a
481
+ # namespace, so `Minitest::Test` does not match a `Minitest` class) and
482
+ # calls a method of the entry point's name. A bare-name grep alone matched
483
+ # unrelated tests for common names such as build, run, and parse.
484
+ def entry_point_hits(test_sources, entry_points)
485
+ found = Hash.new { |hash, file| hash[file] = [] }
486
+ entry_points.each do |name|
487
+ qualifier, _sep, bare = SourceIndex.split_qualified(name)
488
+ owner = qualifier.split("::").last
489
+ call = /\.#{called(bare)}/
490
+ named = owner && /\b#{Regexp.escape(owner)}\b(?!::)/
491
+ # A request names its endpoint by verb and path, never by class.
492
+ call, named = request_pattern(bare), nil if SourceIndex.endpoint?(bare)
493
+ test_sources.each do |file, source|
494
+ found[file] << name if source.match?(call) && (named.nil? || source.match?(named))
495
+ end
496
+ end
497
+ found.keys.sort.first(TEST_CAP).map { |file| { "file" => file, "entry_points" => found[file].sort } }
498
+ end
499
+
500
+ def coverage_hits(method)
501
+ node = method["new"] || method["old"]
502
+ start_line = node.start_line
503
+ end_line = node.end_line
504
+ @coverage.filter_map { |test|
505
+ lines = test.dig("lines", node.file)
506
+ next unless lines.is_a?(Array)
507
+ next unless lines.any? { |line| line.is_a?(Integer) && line >= start_line && line <= end_line }
508
+
509
+ { "file" => test["file"].to_s, "test" => test["id"].to_s, "via" => "coverage" }
510
+ }.sort_by { |hit| [hit["file"], hit["test"]] }.first(TEST_CAP)
511
+ end
512
+
513
+ # A test file that sends the endpoint's verb to a path ending in its
514
+ # segments, as a request spec does.
515
+ def request_hits(test_sources, bare)
516
+ pattern = request_pattern(bare)
517
+ test_sources.filter_map { |file, source| { "file" => file, "via" => "grep" } if source.match?(pattern) }
518
+ .sort_by { |hit| hit["file"] }.first(TEST_CAP)
519
+ end
520
+
521
+ # `post "/v1/things/#{thing.id}/refresh"` for `POST /things/:id/refresh`:
522
+ # any segments before the endpoint's, a `:param` matching any one. A
523
+ # path that starts with an interpolation, as `"#{api_root}/things/..."`,
524
+ # computes its mount point, so it may name only the endpoint's last
525
+ # segments, at least two of them.
526
+ def request_pattern(bare)
527
+ verb, path = bare.split(" ", 2)
528
+ segment = %q{[^"'?/\s]+}
529
+ parts = path.split("/").reject(&:empty?).map { |part| part.start_with?(":") ? segment : Regexp.escape(part) }
530
+ suffixes = parts.size.downto([parts.size, 2].min).map { |count| parts.last(count).join("/") }
531
+ interpolation = '#\{[^}"\'\n]*\}'
532
+ full = "(?:/#{segment})*/#{parts.join("/")}"
533
+ computed_mount = "#{interpolation}(?:#{suffixes.map { |suffix| "/#{suffix}" }.join("|")})"
534
+ Regexp.new(%Q{\\b#{verb.downcase}[\\s(]+["'](?:#{full}|#{computed_mount})/?["'?]})
535
+ end
536
+
537
+ def app_routes(idx)
538
+ Routes.parse(idx["sources"]["config/routes.rb"], app_inflections(idx))
539
+ end
540
+
541
+ def app_inflections(idx)
542
+ Inflections.read(idx["sources"]["config/initializers/inflections.rb"])
543
+ end
544
+
545
+ # Each value the change edits, with the code at head that reads it some
546
+ # way other than a call by name. A row at one of a method's call sites,
547
+ # or a plain read inside a method a constant already lists as reading
548
+ # it, says nothing those lists do not, so it is left out.
549
+ def implicit_readers(base_idx, head_idx, diff, methods, constants, base, head)
550
+ values, rows = Readers.between(
551
+ base_sources: base_idx["sources"], head_sources: head_idx["sources"].merge(head_idx["templates"]),
552
+ diff: diff, routes: app_routes(head_idx), inflections: app_inflections(head_idx)
553
+ )
554
+ items = values.to_h { |value| [value, listed_item(value, methods, constants)] }
555
+ rows = rows.reject { |row| shown_elsewhere?(row, items[row.value], methods) }
556
+ counts = rows.group_by(&:value).transform_values(&:size)
557
+ {
558
+ "values" => values.map { |value|
559
+ { "value" => value.label, "kind" => value.kind.to_s, "change" => value.state.to_s, "id" => items[value]&.fetch("id"),
560
+ "rev" => value.state == :removed ? base : head, "file" => value.file, "line" => value.line, "readers" => counts.fetch(value, 0) }
561
+ },
562
+ "rows" => rows.map { |row|
563
+ { "value" => row.value.label, "id" => items[row.value]&.fetch("id"), "rev" => head, "file" => row.file, "line" => row.line,
564
+ "edited_by_change" => row.edited, "mechanism" => row.mechanism.to_s, "score" => row.score, "rank" => row.rank,
565
+ "why" => row.why, "also" => row.also, "test" => row.test }
566
+ },
567
+ "not_seen" => Readers::BLIND
568
+ }
569
+ end
570
+
571
+ # The method or constant entry a value is, by its name and where it is
572
+ # declared. A setting, a callback, or a default has none.
573
+ def listed_item(value, methods, constants)
574
+ items = { constant: constants, method: methods, scope: methods }.fetch(value.kind, [])
575
+ items.find do |item|
576
+ node = value.state == :removed ? item["old"] : item["new"]
577
+ node && node.file == value.file && node.start_line == value.line && node.bare_name == value.name
578
+ end
579
+ end
580
+
581
+ def shown_elsewhere?(row, item, methods)
582
+ return false unless item
583
+
584
+ if item.key?("callsites")
585
+ item["callsites"].any? { |site| site["file"] == row.file && site["line"] == row.line }
586
+ else
587
+ row.mechanism == :constant_read && methods.any? do |method|
588
+ node = method["new"]
589
+ item["read_by"].include?(method["id"]) && node && node.file == row.file && row.line.between?(node.start_line, node.end_line)
590
+ end
591
+ end
592
+ end
593
+
594
+ # The files whose requests the routes send to the action, as `run`
595
+ # reads them, and the controller tests that name the controller and
596
+ # call the action by its symbol (`post :create`). A bare-name grep
597
+ # matched `create(:book)` and missed `post books_path`.
598
+ def route_hits(test_sources, action, requests)
599
+ owner, _sep, bare = SourceIndex.split_qualified(action)
600
+ controller_call = /\b(?:#{SourceIndex::ENDPOINT_VERBS.join("|")})[\s(]+:#{called(bare)}/
601
+ named = /\b#{Regexp.escape(owner.split("::").last)}\b/
602
+ test_sources.filter_map { |file, source|
603
+ calls = requests[file].filter_map { |request, actions| request if actions.include?(action) }
604
+ calls += source.scan(controller_call).map { |call| call.sub(/[\s(]+/, " ") }.uniq if source.match?(named)
605
+ { "file" => file, "via" => "route", "calls" => calls.sort } unless calls.empty?
606
+ }.first(TEST_CAP)
607
+ end
608
+
609
+ # Each test file's requests, with the controller actions they reach.
610
+ def test_requests(test_sources, routes, base_idx, head_idx)
611
+ test_sources.keys.to_h { |file|
612
+ index = head_idx["indexes"][file] || base_idx["indexes"][file]
613
+ [file, index.referenced_names("").select { |name| SourceIndex.request?(name) }.to_h { |request| [request, routes.actions(request)] }]
614
+ }
615
+ end
616
+
617
+ def grep_hits(test_sources, bare)
618
+ return [] if bare.nil? || bare.length < 3
619
+
620
+ pattern = /\b#{called(bare)}/
621
+ test_sources.filter_map { |file, source|
622
+ next unless source.match?(pattern)
623
+
624
+ { "file" => file, "via" => "grep" }
625
+ }.sort_by { |hit| hit["file"] }.first(TEST_CAP)
626
+ end
627
+
628
+ # A factory that builds a class no file defines may need an inflection
629
+ # the app configures (`ApiKey` against `APIKey`), so it is not followed.
630
+ # One that builds a Ruby value, as `class: Hash` does for the shape of an
631
+ # API response, builds no class of the app, so nothing is skipped. The
632
+ # list is fixed so that every Ruby prints the same document.
633
+ RUBY_VALUE_CLASSES = %w[Array Data Hash Object OpenStruct Set String Struct].freeze
634
+
635
+ def factories_of(idx)
636
+ defined = lookups(idx)[2]
637
+ Factories.read(idx["sources"].select { |path, _source| Factories.file?(path) }).filter_map do |factory|
638
+ next factory if factory.reason || defined.key?(factory.class_name)
639
+ next if RUBY_VALUE_CLASSES.include?(factory.class_name.to_s.delete_prefix("::"))
640
+
641
+ factory.with(reason: "it builds #{factory.class_name}, which no file here defines")
642
+ end
643
+ end
644
+
645
+ # The calls other than `new` that run a changed class's `initialize`:
646
+ # `create`, `create!`, and `build` on an ActiveRecord model, and each
647
+ # factory that builds the class.
648
+ def builders_for(methods, idx, factories)
649
+ by_class, _by_bare, _qualifiers, parents = lookups(idx)
650
+ methods.map { |method| (method["new"] || method["old"]).qualifier }.uniq.to_h do |qualifier|
651
+ records = RECORD_BUILDERS.filter_map { |bare| "#{qualifier}.#{bare}" if constructor_of(qualifier, bare, by_class, parents) }
652
+ [qualifier, (records + factory_mentions(qualifier, factories)).sort]
653
+ end
654
+ end
655
+
656
+ # A factory whose association builds the class runs its constructor and
657
+ # its save callbacks too: `create(:loan)` saves the Borrower that
658
+ # `borrower` builds, and `build(:loan)` builds it unsaved. A trait's
659
+ # associations build only when the test names the trait.
660
+ def factory_mentions(qualifier, factories)
661
+ factories.reject(&:reason).flat_map do |factory|
662
+ traits = factory.trait_builds.select { |_trait, classes| classes.include?(qualifier) }.keys
663
+ traits.unshift(nil) if factory.class_name == qualifier || factory.builds.include?(qualifier)
664
+ traits.product(%w[build create]).map { |trait, call| SourceIndex.factory_mention(factory.name, call, trait) }
665
+ end
666
+ end
667
+
668
+ # The calls that run a changed method because a model's class body
669
+ # names it, or names a method that reaches it, in a callback or a
670
+ # validation (`after_save :count_save`). A factory of the model counts
671
+ # where `.create` does: `create(:loan)` saves a new record. `build(:loan)`
672
+ # counts there too, but a test hit needs the test to save or validate
673
+ # the record as well (`call_hits`).
674
+ def decorate_callbacks(methods, base_idx, head_idx, factories, head_edges, base_edges)
675
+ base_callbacks = record_callbacks(base_idx)
676
+ head_callbacks = record_callbacks(head_idx)
677
+ base_calls = app_calls(base_idx)
678
+ head_calls = app_calls(head_idx)
679
+ methods.each do |method|
680
+ node = method["new"] || method["old"]
681
+ callbacks = method["change"] == "removed" ? base_callbacks : head_callbacks
682
+ found = [node.qualified, *method["reachable_from"]].flat_map { |name| callbacks.fetch(name, []) }
683
+ method["callbacks"] = found.map(&:declared).uniq.sort
684
+ method["run_by"] = found.flat_map { |callback| callback_calls(callback, factories) }.uniq.sort
685
+ calls, edges = method["change"] == "removed" ? [base_calls, base_edges] : [head_calls, head_edges]
686
+ savers = calls.filter_map { |written, from| from if method["run_by"].any? { |run_by| SourceIndex.names_call?(written, run_by) } }.uniq.sort
687
+ reached = savers + savers.flat_map { |name| upstream(name, edges) }
688
+ method["reachable_from"] = (method["reachable_from"] | reached).reject { |name| name == node.qualified }.first(REACHABLE_CAP).sort
689
+ through_saves = savers.flat_map { |name| [[name, 1], *callers_by_depth(name, edges, nil).map { |caller, depth| [caller, depth + 1] }] }
690
+ depths = method["caller_depths"]
691
+ through_saves.each { |name, depth| depths[name] = [depths.fetch(name, depth), depth].min unless name == node.qualified }
692
+ method["caller_depths"] = depths.sort.to_h
693
+ method["reached_by"] = method["caller_depths"].keys
694
+ end
695
+ end
696
+
697
+ # Each call outside the tests as the repository's code writes it, with
698
+ # the method that makes it: `Loan.create!` in `LoanDesk.open`, or
699
+ # `Loan#update!` for `update!` inside a method of `Loan`. A written
700
+ # `Loan` names `Shop::Loan` as it does in a test. A local the source
701
+ # does not type, such as a parameter, is read as the model Rails names
702
+ # it after: `loan.save!` as `Loan#save!`. Only a save reads it, since a
703
+ # record call has no meaning on another class.
704
+ def app_calls(idx)
705
+ idx["calls"].filter_map { |call|
706
+ next if call.enclosing.nil? || test_file?(call.file)
707
+
708
+ written = written_call(call)
709
+ [written, call.enclosing] if written
710
+ }
711
+ end
712
+
713
+ def written_call(call)
714
+ qualifier, sep, _bare = SourceIndex.split_qualified(call.enclosing)
715
+ receiver = call.receiver
716
+ case receiver.kind
717
+ when "self" then "#{qualifier}#{sep}#{call.bare_name}"
718
+ when "singleton" then "#{receiver.const}.#{call.bare_name}"
719
+ when "instance" then "#{receiver.const}##{call.bare_name}"
720
+ when "named" then "#{SourceIndex.model_name(receiver.const)}##{call.bare_name}"
721
+ end
722
+ end
723
+
724
+ # A callback line counts in a class whose superclass chain reaches
725
+ # ActiveRecord::Base. One in a Concern runs on each class that includes
726
+ # it, which the source does not list, so it is not followed.
727
+ def record_callbacks(idx)
728
+ parents = lookups(idx)[3]
729
+ idx["indexes"].reject { |path, _index| test_file?(path) }.values.flat_map(&:callbacks)
730
+ .select { |callback| ancestry(callback.qualifier, parents).include?("ActiveRecord::Base") }
731
+ .group_by { |callback| SourceIndex.qualify(callback.qualifier, callback.method, false) }
732
+ end
733
+
734
+ def callback_calls(callback, factories)
735
+ calls = callback.calls.map { |call| "#{callback.qualifier}#{call}" }
736
+ calls.include?("#{callback.qualifier}.create") ? calls + factory_mentions(callback.qualifier, factories) : calls
737
+ end
738
+
739
+ def constructor?(node)
740
+ node.bare_name == "initialize" && node.kind == "instance" && !node.qualifier.empty?
741
+ end
742
+
743
+ # A test file that makes one of the calls, as its parsed source writes
744
+ # it. `Loan.new` also names `Shop::Loan` from inside `Shop`.
745
+ def call_hits(paths, calls, via, base_idx, head_idx)
746
+ return [] if calls.empty?
747
+
748
+ record_calls = SourceIndex.record_calls(calls) if via == "callback"
749
+ paths.filter_map { |path|
750
+ index = head_idx["indexes"][path] || base_idx["indexes"][path]
751
+ names = (@test_names[path] ||= index.referenced_names(""))
752
+ found = names.select { |name| calls.any? { |call| SourceIndex.names_call?(name, call) } }
753
+ found.reject! { |name| SourceIndex.unsaved_mention?(name) } if record_calls && !SourceIndex.saves_or_validates?(names, record_calls)
754
+ { "file" => path, "via" => via, "calls" => found } unless found.empty?
755
+ }
756
+ end
757
+
758
+ def merge_hits(builds, greps)
759
+ built = builds.map { |hit| hit["file"] }
760
+ (builds + greps.reject { |hit| built.include?(hit["file"]) }).sort_by { |hit| hit["file"] }.first(TEST_CAP)
761
+ end
762
+
763
+ # A method name can end in `!` or `?`, where `\b` cannot follow, and
764
+ # `save` is not a call of `save!`.
765
+ def called(bare)
766
+ "#{Regexp.escape(bare)}(?![\\w?!])"
767
+ end
768
+
769
+ def test_sources_for(base_idx, head_idx)
770
+ paths = (base_idx["sources"].keys | head_idx["sources"].keys).select { |path| test_file?(path) }.sort
771
+ paths.to_h { |path| [path, head_idx["sources"][path] || base_idx["sources"][path]] }
772
+ end
773
+
774
+ def test_file?(path)
775
+ base = File.basename(path)
776
+ return false unless base.end_with?(".rb")
777
+ return true if base.start_with?("test_") || base.end_with?("_test.rb")
778
+
779
+ parts = path.split("/")
780
+ parts.include?("test") || parts.include?("spec")
781
+ end
782
+
783
+ def entrypoints_for(methods, head_idx, base_idx)
784
+ classes = methods.map { |method| (method["new"] || method["old"]).qualifier }.uniq.sort
785
+ classes.filter_map do |qualifier|
786
+ next if qualifier.empty?
787
+
788
+ side = public_methods_of(head_idx, qualifier).empty? ? base_idx : head_idx
789
+ pool = public_methods_of(side, qualifier)
790
+ changed = methods.to_h { |method| [(method["new"] || method["old"]).qualified, method["change"]] }
791
+ constructor = "#{qualifier}#initialize"
792
+ names = pool.map(&:qualified) - [constructor]
793
+ listed = names.map { |name| { "name" => name, "role" => changed[name] || "unchanged" } }
794
+ if [head_idx, base_idx].any? { |idx| idx["methods"].any? { |method| method.qualified == constructor } } && !side["hidden_new"].include?(qualifier)
795
+ # A test builds the object with `.new`, which runs `initialize`.
796
+ listed << { "name" => "#{qualifier}.new", "role" => changed[constructor] || "unchanged" }
797
+ end
798
+ # An unchanged method that reaches a changed one is the call a test
799
+ # makes, so the cap leaves it in before the other unchanged ones.
800
+ callers = methods.flat_map { |method| Array(method["reachable_from"]) }
801
+ listed = listed.sort_by { |item| [item["role"] == "unchanged" ? 1 : 0, item["name"].end_with?(".new") ? 0 : 1, item["role"] == "unchanged" && callers.include?(item["name"]) ? 0 : 1, item["name"]] }
802
+ next if listed.empty?
803
+
804
+ { "class" => qualifier, "methods" => listed.first(ENTRY_CAP), "omitted" => [listed.size - ENTRY_CAP, 0].max }
805
+ end
806
+ end
807
+
808
+ def public_methods_of(idx, qualifier)
809
+ idx["methods"].select { |method| method.qualifier == qualifier && method.visibility == "public" && !test_file?(method.file) }
810
+ end
811
+
812
+ def edges_for(idx)
813
+ by_class, by_bare, qualifiers, parents = lookups(idx)
814
+ edges = []
815
+ named_owners = {}
816
+ (idx["calls"] + idx["template_calls"]).each do |call|
817
+ next if call.enclosing.nil? || test_file?(call.file)
818
+
819
+ targets = if call.enclosing == call.file && call.receiver.kind == "self"
820
+ helper_target(call, by_bare)
821
+ else
822
+ call_target(call, by_class, by_bare, qualifiers, parents, named_owners)
823
+ end
824
+ Array(targets).each do |target|
825
+ next if target.qualified == call.enclosing
826
+
827
+ edges << { "from" => call.enclosing, "to" => target.qualified, "file" => call.file, "line" => call.line }
828
+ end
829
+ end
830
+ edges.uniq { |edge| [edge["from"], edge["to"], edge["file"], edge["line"]] }
831
+ .sort_by { |edge| [edge["file"], edge["line"], edge["from"], edge["to"]] }
832
+ end
833
+
834
+ # A template calls a bare name on its view, which holds the app's helper
835
+ # modules, so only a helper method answers it.
836
+ def helper_target(call, by_bare)
837
+ found = (by_bare[call.bare_name] || []).select { |method| method.kind == "instance" && method.file.start_with?("app/helpers/") }
838
+ found.first if found.size == 1
839
+ end
840
+
841
+ def lookups(idx)
842
+ methods = idx["methods"]
843
+ by_class = methods.group_by { |method| [method.qualifier, method.bare_name] }
844
+ by_bare = methods.group_by(&:bare_name)
845
+ # A class with no method of its own still names a class to look up.
846
+ qualifiers = idx["namespaces"].to_h { |node| [node.qualified, []] }.merge(methods.group_by(&:qualifier))
847
+ parents = idx["superclasses"].to_h { |sup| [sup.qualified, resolve_constant(sup.written, sup.scope, qualifiers, defining: sup.qualified) || sup.written] }
848
+ [by_class, by_bare, qualifiers, parents]
849
+ end
850
+
851
+ # A call on a receiver the source does not name gets no edge: a bare
852
+ # name alone would send `line.strip` to any indexed `strip`. An implicit
853
+ # `self` call still falls back to a unique bare name, for methods a class
854
+ # gets from a module or a superclass; from an instance method, only to
855
+ # an instance method, since no mixin turns a class method into one.
856
+ # `named_owners` keeps the class each name resolves to from each
857
+ # namespace, since a repository sends to the same few names many times.
858
+ def call_target(call, by_class, by_bare, qualifiers, parents, named_owners = {})
859
+ qualifier, sep, _bare = SourceIndex.split_qualified(call.enclosing)
860
+ receiver = call.receiver
861
+ case receiver.kind
862
+ when "self"
863
+ same = by_class[[qualifier, call.bare_name]] || []
864
+ instance = same.select { |method| method.kind == "instance" }
865
+ if instance.size == 1
866
+ instance.first
867
+ elsif same.size == 1
868
+ same.first
869
+ elsif sep == "." && (built = constructor_of(qualifier, call.bare_name, by_class, parents))
870
+ built
871
+ else
872
+ inherited = (by_bare[call.bare_name] || []).select { |method| sep == "." || method.kind == "instance" }
873
+ inherited.first if inherited.size == 1
874
+ end
875
+ when "singleton", "instance"
876
+ owner = receiver.const.nil? ? qualifier : resolve_constant(receiver.const, qualifier, qualifiers)
877
+ found = (by_class[[owner, call.bare_name]] || []).select { |method| method.kind == receiver.kind }
878
+ if found.size == 1
879
+ found.first
880
+ elsif receiver.kind == "singleton"
881
+ constructor_of(owner, call.bare_name, by_class, parents)
882
+ end
883
+ when "named"
884
+ key = [qualifier, receiver.const]
885
+ owner = named_owners.fetch(key) { named_owners[key] = resolve_constant(SourceIndex.model_name(receiver.const), qualifier, qualifiers) }
886
+ return nil unless owner
887
+
888
+ found = (by_class[[owner, call.bare_name]] || []).select { |method| method.kind == "instance" }
889
+ found.first if found.size == 1
890
+ when "one_of"
891
+ receiver.const.filter_map do |const|
892
+ found = (by_class[[resolve_constant(const, qualifier, qualifiers), call.bare_name]] || []).select { |method| method.kind == "singleton" }
893
+ found.first if found.size == 1
894
+ end
895
+ end
896
+ end
897
+
898
+ # `new` runs the nearest `initialize` up the superclass chain. On an
899
+ # ActiveRecord model, `create`, `create!`, and `build` call `new`; on
900
+ # another class they are methods the index cannot see, so they count
901
+ # only there. A class method of the same name up the chain answers the
902
+ # call instead.
903
+ def constructor_of(owner, bare, by_class, parents)
904
+ chain = ancestry(owner, parents)
905
+ return nil unless bare == "new" || (RECORD_BUILDERS.include?(bare) && chain.include?("ActiveRecord::Base"))
906
+ return nil if chain.any? { |klass| Array(by_class[[klass, bare]]).any? { |method| method.kind == "singleton" } }
907
+
908
+ chain.each do |klass|
909
+ found = Array(by_class[[klass, "initialize"]]).select { |method| method.kind == "instance" }
910
+ return found.first if found.size == 1
911
+ end
912
+ nil
913
+ end
914
+
915
+ def ancestry(owner, parents)
916
+ chain = [owner]
917
+ chain << parents[chain.last] while parents.key?(chain.last) && !chain.include?(parents[chain.last])
918
+ chain
919
+ end
920
+
921
+ # Ruby looks a constant up from the innermost enclosing namespace out.
922
+ # A class header reads its superclass before it defines the class, so
923
+ # the superclass is never the class it heads.
924
+ def resolve_constant(const, qualifier, qualifiers, defining: nil)
925
+ parts = qualifier.split("::")
926
+ parts.size.downto(0).map { |n| [*parts.first(n), const].join("::") }.find { |name| name != defining && qualifiers.key?(name) }
927
+ end
928
+
929
+ # The cap keeps the nearest callers; the kept names print sorted. A
930
+ # caller of `new` reaches `initialize` in the call it writes, so that
931
+ # hop adds no depth, and its callers go first in the queue.
932
+ def upstream(qualified, edges)
933
+ callers_by_depth(qualified, edges, UPSTREAM_DEPTH).sort_by { |name, depth| [depth, name] }.first(REACHABLE_CAP).map(&:first).sort
934
+ end
935
+
936
+ def callers_by_depth(qualified, edges, limit)
937
+ reverse = callers_index(edges)
938
+ depths = {}
939
+ queue = reverse.fetch(qualified, []).uniq.map { |name| [name, 1] }
940
+ until queue.empty?
941
+ name, depth = queue.shift
942
+ next if name == qualified || depths.key?(name)
943
+
944
+ depths[name] = depth
945
+ if name.end_with?("#initialize")
946
+ reverse.fetch(name, []).each { |parent| queue.unshift([parent, depth]) unless depths.key?(parent) }
947
+ next
948
+ end
949
+ next if limit && depth >= limit
950
+
951
+ reverse.fetch(name, []).each { |parent| queue << [parent, depth + 1] unless depths.key?(parent) }
952
+ end
953
+ depths
954
+ end
955
+
956
+ def callers_index(edges)
957
+ (@callers_index ||= {}.compare_by_identity)[edges] ||= edges.group_by { |edge| edge["to"] }.transform_values { |list| list.map { |edge| edge["from"] } }
958
+ end
959
+
960
+ def intent_lines(base, head)
961
+ messages = @git.commit_messages(base, head).filter_map do |message|
962
+ lines = message.lines.map(&:strip).reject(&:empty?)
963
+ lines.empty? ? nil : lines
964
+ end
965
+
966
+ messages.flat_map.with_index { |lines, index| index.zero? ? lines : ["---", *lines] }
967
+ end
968
+
969
+ def method_hash(item)
970
+ node = item["new"] || item["old"]
971
+ old_node = item["old"]
972
+ new_node = item["new"]
973
+ {
974
+ "id" => item["id"],
975
+ "change" => item["change"],
976
+ "file" => node.file,
977
+ "name" => node.qualified,
978
+ "bare_name" => node.bare_name,
979
+ "kind" => node.kind,
980
+ "visibility" => (new_node || old_node).visibility,
981
+ "signature" => signature_of(item["change"], old_node, new_node),
982
+ "start_line" => (new_node || old_node).start_line,
983
+ "end_line" => (new_node || old_node).end_line,
984
+ "old_start" => old_node&.start_line,
985
+ "old_end" => old_node&.end_line,
986
+ "new_start" => new_node&.start_line,
987
+ "new_end" => new_node&.end_line,
988
+ "old_source" => cap(Text.dedent(old_node&.source)),
989
+ "new_source" => cap(Text.dedent(new_node&.source)),
990
+ "callsites" => item["callsites"],
991
+ "callsites_omitted" => item["callsites_omitted"],
992
+ "reachable_from" => item["reachable_from"],
993
+ "reached_by" => item["reached_by"],
994
+ "caller_depths" => item["caller_depths"],
995
+ "dynamic_callers" => item["dynamic_callers"],
996
+ "builders" => item["builders"],
997
+ "callbacks" => item["callbacks"],
998
+ "run_by" => item["run_by"],
999
+ "existing_tests" => item["existing_tests"],
1000
+ "entry_point_tests" => item["entry_point_tests"],
1001
+ "via_constant" => item["via_constant"],
1002
+ "forwards_to_added" => item["forwards_to_added"],
1003
+ "moved_from" => item["moved_from"]
1004
+ }
1005
+ end
1006
+
1007
+ def constant_hash(item)
1008
+ node = item["new"] || item["old"]
1009
+ {
1010
+ "id" => item["id"],
1011
+ "change" => item["change"],
1012
+ "file" => node.file,
1013
+ "name" => node.qualified,
1014
+ "bare_name" => node.bare_name,
1015
+ "start_line" => node.start_line,
1016
+ "end_line" => node.end_line,
1017
+ "old_source" => item["old"]&.source,
1018
+ "new_source" => item["new"]&.source,
1019
+ "read_by" => item["read_by"],
1020
+ "moved_from" => item["moved_from"]
1021
+ }
1022
+ end
1023
+
1024
+ def signature_of(change, old_node, new_node)
1025
+ return "added" if change == "added"
1026
+ return "removed" if change == "removed"
1027
+
1028
+ old_node.params == new_node.params ? "same" : "changed"
1029
+ end
1030
+
1031
+ def assign_ids(items, prefix)
1032
+ items.sort_by! { |item| identity_sort(item) }
1033
+ width = [2, items.size.to_s.length].max
1034
+ items.each_with_index { |item, index| item["id"] = format("%s%0#{width}d", prefix, index + 1) }
1035
+ end
1036
+
1037
+ def identity_sort(item)
1038
+ node = item["new"] || item["old"]
1039
+ [node.file, node.qualified, item["change"]]
1040
+ end
1041
+
1042
+ def identity(node)
1043
+ [node.file, node.qualified]
1044
+ end
1045
+
1046
+ def index_by(nodes)
1047
+ nodes.to_h { |node| [identity(node), node] }
1048
+ end
1049
+
1050
+ def norm(text)
1051
+ text.to_s.rstrip
1052
+ end
1053
+
1054
+ def cap(text)
1055
+ Text.cap_lines(text)
1056
+ end
1057
+ end
1058
+
1059
+ module CoverageFile
1060
+ module_function
1061
+
1062
+ def load(path)
1063
+ expanded = File.expand_path(path)
1064
+ raise UsageError, "coverage file not found: #{path}" unless File.file?(expanded)
1065
+
1066
+ raw = JSON.parse(File.read(expanded))
1067
+ unless raw.is_a?(Hash) && raw["format"] == "jitcatch-coverage-v1" && raw["tests"].is_a?(Array)
1068
+ raise UsageError, "coverage file must be JSON object with format jitcatch-coverage-v1 and a tests array"
1069
+ end
1070
+
1071
+ raw["tests"].map { |test|
1072
+ raise UsageError, "coverage test entries must be objects" unless test.is_a?(Hash)
1073
+
1074
+ {
1075
+ "id" => test["id"].to_s,
1076
+ "file" => test["file"].to_s,
1077
+ "lines" => test["lines"].is_a?(Hash) ? test["lines"] : {}
1078
+ }
1079
+ }.sort_by { |test| [test["file"], test["id"]] }
1080
+ rescue JSON::ParserError => err
1081
+ raise UsageError, "coverage file is not JSON: #{err.message}"
1082
+ end
1083
+ end
1084
+ end