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,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
|