@reviewflow/sdk 0.0.0-stage → 6.21.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.
- package/LICENSE +15 -0
- package/README.md +86 -2
- package/build/files-node.mjs +70 -0
- package/build/git-node.mjs +1792 -0
- package/build/index-browser.es.js +3338 -0
- package/build/index-node.mjs +3338 -0
- package/package.json +136 -4
- package/src/api/pullRequest.ts +617 -0
- package/src/config/insertCheckoutEntry.ts +54 -0
- package/src/diff/diffPaths.ts +78 -0
- package/src/diff/impacts.ts +47 -0
- package/src/diff/readingOrder.ts +428 -0
- package/src/diff/themes.ts +105 -0
- package/src/diff/types.ts +141 -0
- package/src/files/editableFile.ts +163 -0
- package/src/files.ts +13 -0
- package/src/git/groupDiffPaths.ts +134 -0
- package/src/git/manifests.ts +96 -0
- package/src/git/moduleBindings.ts +571 -0
- package/src/git/pullRequestDiff.ts +159 -0
- package/src/git/readBranchDiff.ts +359 -0
- package/src/git/readImpacts.ts +797 -0
- package/src/git/readingOrder.ts +190 -0
- package/src/git/run.ts +213 -0
- package/src/git.ts +38 -0
- package/src/github/applySuggestions.ts +399 -0
- package/src/github/editPullRequest.ts +790 -0
- package/src/github/fetchCompareDiff.ts +21 -0
- package/src/github/imageProxies.ts +43 -0
- package/src/github/lineDiff.ts +145 -0
- package/src/github/prQuery.ts +298 -0
- package/src/github/pullRequestDetail.ts +1347 -0
- package/src/github/pullRequestHead.ts +65 -0
- package/src/github/pullRequestRevisions.ts +124 -0
- package/src/github/transport.ts +171 -0
- package/src/index.ts +35 -0
- package/src/reviewflow/board.ts +333 -0
- package/src/reviewflow/changedParts.ts +80 -0
- package/src/reviewflow/checksFromReviewflow.ts +75 -0
- package/src/reviewflow/client.ts +589 -0
- package/src/reviewflow/follow.ts +345 -0
- package/src/reviewflow/status.ts +17 -0
- package/src/reviewflow/stream.ts +199 -0
- package/src/types/entities.ts +8 -0
- package/src/types/ids.ts +1 -0
- package/src/types/reviewflow.ts +32 -0
- package/src/utils/pullRequestEdit.ts +108 -0
- package/src/utils/pullRequestPart.ts +12 -0
- package/src/utils/pullRequestReaction.ts +19 -0
- package/src/utils/pullRequestSuggestion.ts +148 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { builtinModules } from "node:module";
|
|
2
|
+
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
|
|
3
|
+
import type {
|
|
4
|
+
DiffExportImpact,
|
|
5
|
+
DiffFileImpacts,
|
|
6
|
+
DiffImpactsResponse,
|
|
7
|
+
} from "../diff/impacts.ts";
|
|
8
|
+
import {
|
|
9
|
+
expand,
|
|
10
|
+
isAttachablePath,
|
|
11
|
+
isTypescriptPath,
|
|
12
|
+
resolveRelative,
|
|
13
|
+
specificity,
|
|
14
|
+
targetOf,
|
|
15
|
+
} from "../diff/readingOrder.ts";
|
|
16
|
+
import {
|
|
17
|
+
dependencyNamesOf,
|
|
18
|
+
manifestDirectoryOf,
|
|
19
|
+
readManifests,
|
|
20
|
+
resolveLocalSpecifier,
|
|
21
|
+
} from "./manifests.ts";
|
|
22
|
+
import type { Manifest } from "./manifests.ts";
|
|
23
|
+
import {
|
|
24
|
+
exportedBindings,
|
|
25
|
+
exportedNames,
|
|
26
|
+
moduleBindings,
|
|
27
|
+
starSpecifiers,
|
|
28
|
+
touchedExports,
|
|
29
|
+
} from "./moduleBindings.ts";
|
|
30
|
+
import type { ImportedBinding, LineRange, ReExport } from "./moduleBindings.ts";
|
|
31
|
+
import { ensureCommits, isSha } from "./pullRequestDiff.ts";
|
|
32
|
+
import { readNumstat } from "./readBranchDiff.ts";
|
|
33
|
+
import type { NumstatEntry } from "./readBranchDiff.ts";
|
|
34
|
+
import { catFiles, git } from "./run.ts";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Past this many TypeScript files at `head`, nothing is analyzed. Measured on
|
|
38
|
+
* this repository (see `docs/app-review-overview-impacts.md`).
|
|
39
|
+
*/
|
|
40
|
+
export const maxTypescriptFiles = 20_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Wall time after which reading and parsing the blobs not cached gives up.
|
|
44
|
+
* What was parsed stays cached, so the next read gets further.
|
|
45
|
+
*/
|
|
46
|
+
export const maxReadMs = 15_000;
|
|
47
|
+
|
|
48
|
+
const batchSize = 250;
|
|
49
|
+
const diffChunkSize = 200;
|
|
50
|
+
const maxTreeBuffer = 256 * 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/** What a blob contributes to the graph, independent of the tree it is in. */
|
|
53
|
+
export interface ModuleSummary {
|
|
54
|
+
exports: string[];
|
|
55
|
+
imports: ImportedBinding[];
|
|
56
|
+
reExports: ReExport[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parsed bindings by blob oid: the same content parses once, whatever the commit. */
|
|
60
|
+
export interface BindingsCache {
|
|
61
|
+
get: (oid: string) => ModuleSummary | undefined;
|
|
62
|
+
set: (oid: string, summary: ModuleSummary) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const defaultCacheEntries = 2 * maxTypescriptFiles;
|
|
66
|
+
|
|
67
|
+
/** Bounded, evicting the blob least recently read or written. */
|
|
68
|
+
export const createBindingsCache = (
|
|
69
|
+
maxEntries: number = defaultCacheEntries,
|
|
70
|
+
): BindingsCache => {
|
|
71
|
+
const entries = new Map<string, ModuleSummary>();
|
|
72
|
+
return {
|
|
73
|
+
get: (oid) => {
|
|
74
|
+
const summary = entries.get(oid);
|
|
75
|
+
if (summary === undefined) return undefined;
|
|
76
|
+
entries.delete(oid);
|
|
77
|
+
entries.set(oid, summary);
|
|
78
|
+
return summary;
|
|
79
|
+
},
|
|
80
|
+
set: (oid, summary) => {
|
|
81
|
+
entries.delete(oid);
|
|
82
|
+
entries.set(oid, summary);
|
|
83
|
+
for (const oldest of entries.keys()) {
|
|
84
|
+
if (entries.size <= maxEntries) break;
|
|
85
|
+
entries.delete(oldest);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
interface TreeBlob {
|
|
92
|
+
path: string;
|
|
93
|
+
oid: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The files at `sha` with their blob oids; a submodule is not one. */
|
|
97
|
+
const listBlobs = async (
|
|
98
|
+
checkout: string,
|
|
99
|
+
sha: string,
|
|
100
|
+
): Promise<TreeBlob[]> => {
|
|
101
|
+
const listing = await git(checkout, ["ls-tree", "-r", "-z", sha], {
|
|
102
|
+
maxBuffer: maxTreeBuffer,
|
|
103
|
+
});
|
|
104
|
+
return listing.split("\0").flatMap((record) => {
|
|
105
|
+
const tab = record.indexOf("\t");
|
|
106
|
+
if (tab === -1) return [];
|
|
107
|
+
const [, type, oid] = record.slice(0, tab).split(" ");
|
|
108
|
+
return type === "blob" && oid !== undefined
|
|
109
|
+
? [{ path: record.slice(tab + 1), oid }]
|
|
110
|
+
: [];
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const isInNodeModules = (path: string): boolean =>
|
|
115
|
+
path.split("/").includes("node_modules");
|
|
116
|
+
|
|
117
|
+
const emptySummary: ModuleSummary = { exports: [], imports: [], reExports: [] };
|
|
118
|
+
|
|
119
|
+
const summarize = (path: string, source: string): ModuleSummary => {
|
|
120
|
+
const { bindings, reExports, imports } = moduleBindings(path, source);
|
|
121
|
+
return {
|
|
122
|
+
exports: exportedNames({ bindings, reExports }),
|
|
123
|
+
imports,
|
|
124
|
+
reExports,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const chunksOf = <Item>(items: readonly Item[], size: number): Item[][] =>
|
|
129
|
+
Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
|
|
130
|
+
items.slice(index * size, (index + 1) * size),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
interface ReadSummariesParams {
|
|
134
|
+
checkout: string;
|
|
135
|
+
head: string;
|
|
136
|
+
blobs: readonly TreeBlob[];
|
|
137
|
+
cache: BindingsCache;
|
|
138
|
+
budgetMs: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The summary of every blob, by oid: from the cache, else read in batches of
|
|
143
|
+
* files, parsing yielding to the event loop between two batches so a large
|
|
144
|
+
* tree does not hold the server. null past `budgetMs`.
|
|
145
|
+
*/
|
|
146
|
+
const readSummaries = async ({
|
|
147
|
+
checkout,
|
|
148
|
+
head,
|
|
149
|
+
blobs,
|
|
150
|
+
cache,
|
|
151
|
+
budgetMs,
|
|
152
|
+
}: ReadSummariesParams): Promise<Map<string, ModuleSummary> | null> => {
|
|
153
|
+
const summaries = new Map<string, ModuleSummary>();
|
|
154
|
+
const missing = new Map<string, string>();
|
|
155
|
+
for (const { oid, path } of blobs) {
|
|
156
|
+
const cached = cache.get(oid);
|
|
157
|
+
if (cached === undefined) missing.set(oid, path);
|
|
158
|
+
else summaries.set(oid, cached);
|
|
159
|
+
}
|
|
160
|
+
const deadline = Date.now() + budgetMs;
|
|
161
|
+
for (const batch of chunksOf([...missing], batchSize)) {
|
|
162
|
+
if (Date.now() > deadline) return null;
|
|
163
|
+
const contents = await catFiles(
|
|
164
|
+
checkout,
|
|
165
|
+
head,
|
|
166
|
+
batch.map(([, path]) => path),
|
|
167
|
+
);
|
|
168
|
+
for (const [oid, path] of batch) {
|
|
169
|
+
const source = contents.get(path);
|
|
170
|
+
const summary =
|
|
171
|
+
source === undefined ? emptySummary : summarize(path, source);
|
|
172
|
+
summaries.set(oid, summary);
|
|
173
|
+
cache.set(oid, summary);
|
|
174
|
+
}
|
|
175
|
+
await yieldToEventLoop();
|
|
176
|
+
}
|
|
177
|
+
return summaries;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const hunkHeader = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The lines of the new side a `git diff -U0` touches, by file. A pure deletion
|
|
184
|
+
* touches the line it sits before.
|
|
185
|
+
*/
|
|
186
|
+
export const parseTouchedLines = (diff: string): Map<string, LineRange[]> => {
|
|
187
|
+
const touched = new Map<string, LineRange[]>();
|
|
188
|
+
let current: LineRange[] | null = null;
|
|
189
|
+
for (const line of diff.split("\n")) {
|
|
190
|
+
if (line.startsWith("diff --git ")) {
|
|
191
|
+
current = null;
|
|
192
|
+
} else if (line.startsWith("+++ ")) {
|
|
193
|
+
const path = line.slice(4).split("\t")[0]!;
|
|
194
|
+
if (path.startsWith("b/")) {
|
|
195
|
+
current = touched.get(path.slice(2)) ?? [];
|
|
196
|
+
touched.set(path.slice(2), current);
|
|
197
|
+
}
|
|
198
|
+
} else if (line.startsWith("@@")) {
|
|
199
|
+
const match = hunkHeader.exec(line);
|
|
200
|
+
if (current === null || match === null) continue;
|
|
201
|
+
const start = Number(match[1]);
|
|
202
|
+
const count = match[2] === undefined ? 1 : Number(match[2]);
|
|
203
|
+
current.push(
|
|
204
|
+
count === 0
|
|
205
|
+
? { start: start + 1, end: start + 1 }
|
|
206
|
+
: { start, end: start + count - 1 },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return touched;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
interface ReadTouchedLinesParams {
|
|
214
|
+
checkout: string;
|
|
215
|
+
mergeBase: string;
|
|
216
|
+
head: string;
|
|
217
|
+
entries: readonly NumstatEntry[];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const readTouchedLines = async ({
|
|
221
|
+
checkout,
|
|
222
|
+
mergeBase,
|
|
223
|
+
head,
|
|
224
|
+
entries,
|
|
225
|
+
}: ReadTouchedLinesParams): Promise<Map<string, LineRange[]>> => {
|
|
226
|
+
const touched = new Map<string, LineRange[]>();
|
|
227
|
+
// both sides of a rename stay in one chunk, or it is read as an added file
|
|
228
|
+
for (const chunk of chunksOf(entries, diffChunkSize)) {
|
|
229
|
+
const diff = await git(
|
|
230
|
+
checkout,
|
|
231
|
+
[
|
|
232
|
+
"-c",
|
|
233
|
+
"core.quotePath=false",
|
|
234
|
+
"diff",
|
|
235
|
+
"-U0",
|
|
236
|
+
"-M",
|
|
237
|
+
"--no-color",
|
|
238
|
+
"--no-ext-diff",
|
|
239
|
+
"--no-textconv",
|
|
240
|
+
"--no-relative",
|
|
241
|
+
"--src-prefix=a/",
|
|
242
|
+
"--dst-prefix=b/",
|
|
243
|
+
mergeBase,
|
|
244
|
+
head,
|
|
245
|
+
"--",
|
|
246
|
+
...chunk.flatMap(({ paths }) =>
|
|
247
|
+
paths.map((path) => `:(literal)${path}`),
|
|
248
|
+
),
|
|
249
|
+
],
|
|
250
|
+
{ maxBuffer: maxTreeBuffer },
|
|
251
|
+
);
|
|
252
|
+
for (const [path, ranges] of parseTouchedLines(diff)) {
|
|
253
|
+
touched.set(path, ranges);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return touched;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** `@scope/name/sub/path` as `@scope/name` and `./sub/path`. */
|
|
260
|
+
const splitPackageSpecifier = (
|
|
261
|
+
specifier: string,
|
|
262
|
+
): { name: string; subpath: string } => {
|
|
263
|
+
const segments = specifier.split("/");
|
|
264
|
+
const nameLength = specifier.startsWith("@") ? 2 : 1;
|
|
265
|
+
const rest = segments.slice(nameLength).join("/");
|
|
266
|
+
return {
|
|
267
|
+
name: segments.slice(0, nameLength).join("/"),
|
|
268
|
+
subpath: rest === "" ? "." : `./${rest}`,
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const builtins = new Set(builtinModules);
|
|
273
|
+
|
|
274
|
+
const urlScheme = /^[a-z][a-z0-9+.-]*:/;
|
|
275
|
+
|
|
276
|
+
/** `@scope/name` as `@types/scope__name`, `name` as `@types/name`. */
|
|
277
|
+
const typesPackageOf = (name: string): string =>
|
|
278
|
+
`@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}`;
|
|
279
|
+
|
|
280
|
+
/** `@/lib` and `~/lib` are aliases whatever the manifests say. */
|
|
281
|
+
const isConventionalAlias = (specifier: string): boolean =>
|
|
282
|
+
specifier.startsWith("@/") || specifier.startsWith("~");
|
|
283
|
+
|
|
284
|
+
/** What is neither the repository's nor a path alias: a built-in, a URL-like specifier, an absolute path. */
|
|
285
|
+
const isBuiltinOrUrl = (specifier: string, name: string): boolean =>
|
|
286
|
+
urlScheme.test(specifier) ||
|
|
287
|
+
specifier.startsWith("/") ||
|
|
288
|
+
builtins.has(specifier) ||
|
|
289
|
+
builtins.has(name);
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* What a specifier names. A relative or `#` specifier naming no file is
|
|
293
|
+
* `external`: every changed file exists at `head`, so it is never one of them.
|
|
294
|
+
* So is a bare specifier that is a node built-in or a dependency of the
|
|
295
|
+
* importing package (or of the root's). `alias` and `workspaceMiss` are the
|
|
296
|
+
* ones that could hide a user of a changed file: a bare specifier that is
|
|
297
|
+
* none of those, taken for a `paths` alias resolved within the importer's
|
|
298
|
+
* package; a workspace package whose entry names no file (`directory`: where
|
|
299
|
+
* that package is).
|
|
300
|
+
*/
|
|
301
|
+
type Resolution =
|
|
302
|
+
| { kind: "alias" }
|
|
303
|
+
| { kind: "external" }
|
|
304
|
+
| { kind: "file"; path: string }
|
|
305
|
+
| { kind: "workspaceMiss"; directory: string };
|
|
306
|
+
|
|
307
|
+
const external: Resolution = { kind: "external" };
|
|
308
|
+
|
|
309
|
+
interface ResolverParams {
|
|
310
|
+
exists: (path: string) => boolean;
|
|
311
|
+
manifests: ReadonlyMap<string, Manifest>;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const createResolver = ({
|
|
315
|
+
exists,
|
|
316
|
+
manifests,
|
|
317
|
+
}: ResolverParams): ((from: string, specifier: string) => Resolution) => {
|
|
318
|
+
const directoryOfName = new Map<string, string>();
|
|
319
|
+
for (const [directory, { name }] of manifests) {
|
|
320
|
+
if (typeof name === "string") directoryOfName.set(name, directory);
|
|
321
|
+
}
|
|
322
|
+
const dependenciesByDirectory = new Map<string, Set<string>>();
|
|
323
|
+
const dependenciesOf = (directory: string): ReadonlySet<string> => {
|
|
324
|
+
const known = dependenciesByDirectory.get(directory);
|
|
325
|
+
if (known !== undefined) return known;
|
|
326
|
+
const manifest = manifests.get(directory);
|
|
327
|
+
const names = new Set(
|
|
328
|
+
manifest === undefined ? [] : dependencyNamesOf(manifest),
|
|
329
|
+
);
|
|
330
|
+
dependenciesByDirectory.set(directory, names);
|
|
331
|
+
return names;
|
|
332
|
+
};
|
|
333
|
+
/** declared by the nearest `package.json` of the importer, or by the root's */
|
|
334
|
+
const isDependencyOf = (from: string, name: string): boolean => {
|
|
335
|
+
const nearest = manifestDirectoryOf(from, manifests);
|
|
336
|
+
const wanted = [name, typesPackageOf(name)];
|
|
337
|
+
return [nearest, ""].some(
|
|
338
|
+
(directory) =>
|
|
339
|
+
directory !== null &&
|
|
340
|
+
wanted.some((candidate) => dependenciesOf(directory).has(candidate)),
|
|
341
|
+
);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** a target of a manifest, relative to its directory */
|
|
345
|
+
const resolveTarget = (directory: string, target: unknown): string | null =>
|
|
346
|
+
typeof target === "string" && target.startsWith("./")
|
|
347
|
+
? resolveRelative(`${directory}/package.json`, target, exists)
|
|
348
|
+
: null;
|
|
349
|
+
|
|
350
|
+
const resolveInPackage = (
|
|
351
|
+
directory: string,
|
|
352
|
+
subpath: string,
|
|
353
|
+
): string | null => {
|
|
354
|
+
const manifest = manifests.get(directory)!;
|
|
355
|
+
const { exports } = manifest;
|
|
356
|
+
if (exports === undefined) {
|
|
357
|
+
return subpath === "."
|
|
358
|
+
? resolveTarget(directory, targetOf(manifest.types) ?? manifest.main)
|
|
359
|
+
: resolveRelative(`${directory}/package.json`, subpath, exists);
|
|
360
|
+
}
|
|
361
|
+
const isSubpathMap =
|
|
362
|
+
typeof exports === "object" &&
|
|
363
|
+
exports !== null &&
|
|
364
|
+
Object.keys(exports).some((key) => key.startsWith("."));
|
|
365
|
+
if (!isSubpathMap) {
|
|
366
|
+
return subpath === "."
|
|
367
|
+
? resolveTarget(directory, targetOf(exports))
|
|
368
|
+
: null;
|
|
369
|
+
}
|
|
370
|
+
const patterns = Object.entries(exports as Record<string, unknown>)
|
|
371
|
+
.filter(([pattern]) => pattern.startsWith("."))
|
|
372
|
+
.toSorted(([left], [right]) => specificity(right) - specificity(left));
|
|
373
|
+
for (const [pattern, value] of patterns) {
|
|
374
|
+
const captured = expand(pattern, subpath);
|
|
375
|
+
if (captured === null) continue;
|
|
376
|
+
const target = targetOf(value);
|
|
377
|
+
return resolveTarget(
|
|
378
|
+
directory,
|
|
379
|
+
typeof target === "string" ? target.replace("*", captured) : null,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
return (from, specifier) => {
|
|
386
|
+
if (specifier.startsWith(".") || specifier.startsWith("#")) {
|
|
387
|
+
const path = resolveLocalSpecifier({
|
|
388
|
+
from,
|
|
389
|
+
specifier,
|
|
390
|
+
exists,
|
|
391
|
+
manifests,
|
|
392
|
+
});
|
|
393
|
+
return path === null ? external : { kind: "file", path };
|
|
394
|
+
}
|
|
395
|
+
if (isConventionalAlias(specifier)) return { kind: "alias" };
|
|
396
|
+
const { name, subpath } = splitPackageSpecifier(specifier);
|
|
397
|
+
const directory = directoryOfName.get(name);
|
|
398
|
+
if (directory !== undefined) {
|
|
399
|
+
const path = resolveInPackage(directory, subpath);
|
|
400
|
+
return path === null
|
|
401
|
+
? { kind: "workspaceMiss", directory }
|
|
402
|
+
: { kind: "file", path };
|
|
403
|
+
}
|
|
404
|
+
if (isBuiltinOrUrl(specifier, name) || isDependencyOf(from, name)) {
|
|
405
|
+
return external;
|
|
406
|
+
}
|
|
407
|
+
return { kind: "alias" };
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const maxStarHops = 4;
|
|
412
|
+
|
|
413
|
+
interface ReadSummariesAtParams {
|
|
414
|
+
checkout: string;
|
|
415
|
+
ref: string;
|
|
416
|
+
roots: readonly string[];
|
|
417
|
+
resolveFile: (from: string, specifier: string) => string | null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The summaries of `roots` at `ref`, and of the modules their `export *` reach,
|
|
422
|
+
* within `maxStarHops`. A file missing at `ref` has none.
|
|
423
|
+
*/
|
|
424
|
+
const readSummariesAt = async ({
|
|
425
|
+
checkout,
|
|
426
|
+
ref,
|
|
427
|
+
roots,
|
|
428
|
+
resolveFile,
|
|
429
|
+
}: ReadSummariesAtParams): Promise<Map<string, ModuleSummary>> => {
|
|
430
|
+
const found = new Map<string, ModuleSummary>();
|
|
431
|
+
let toRead = [...new Set(roots)];
|
|
432
|
+
for (let hop = 0; hop < maxStarHops && toRead.length > 0; hop++) {
|
|
433
|
+
const contents = await catFiles(checkout, ref, toRead);
|
|
434
|
+
const next = new Set<string>();
|
|
435
|
+
for (const path of toRead) {
|
|
436
|
+
const source = contents.get(path);
|
|
437
|
+
if (source === undefined) continue;
|
|
438
|
+
const summary = summarize(path, source);
|
|
439
|
+
found.set(path, summary);
|
|
440
|
+
for (const specifier of starSpecifiers(summary.reExports)) {
|
|
441
|
+
const target = resolveFile(path, specifier);
|
|
442
|
+
if (target !== null && !found.has(target)) next.add(target);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
toRead = [...next];
|
|
446
|
+
}
|
|
447
|
+
return found;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
interface StarNamesParams {
|
|
451
|
+
/** the file holding the `export *` */
|
|
452
|
+
from: string;
|
|
453
|
+
specifier: string;
|
|
454
|
+
summaryOf: (path: string) => ModuleSummary | undefined;
|
|
455
|
+
resolveFile: (from: string, specifier: string) => string | null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The names an `export * from` passes on: those of its target, and of the
|
|
460
|
+
* targets of the `export *` of that one, `default` apart. null when a module
|
|
461
|
+
* of the chain is unknown.
|
|
462
|
+
*/
|
|
463
|
+
const starNames = ({
|
|
464
|
+
from,
|
|
465
|
+
specifier,
|
|
466
|
+
summaryOf,
|
|
467
|
+
resolveFile,
|
|
468
|
+
}: StarNamesParams): string[] | null => {
|
|
469
|
+
const names = new Set<string>();
|
|
470
|
+
const visited = new Set<string>();
|
|
471
|
+
const visit = (importer: string, request: string): boolean => {
|
|
472
|
+
const target = resolveFile(importer, request);
|
|
473
|
+
if (target === null) return false;
|
|
474
|
+
if (visited.has(target)) return true;
|
|
475
|
+
visited.add(target);
|
|
476
|
+
const summary = summaryOf(target);
|
|
477
|
+
if (summary === undefined) return false;
|
|
478
|
+
for (const name of summary.exports) {
|
|
479
|
+
if (name !== "default") names.add(name);
|
|
480
|
+
}
|
|
481
|
+
return starSpecifiers(summary.reExports).every((next) =>
|
|
482
|
+
visit(target, next),
|
|
483
|
+
);
|
|
484
|
+
};
|
|
485
|
+
return visit(from, specifier) ? [...names] : null;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
interface Importer {
|
|
489
|
+
from: string;
|
|
490
|
+
names: readonly string[];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
interface Passer {
|
|
494
|
+
from: string;
|
|
495
|
+
local: string;
|
|
496
|
+
exported: string;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
interface ReverseGraph {
|
|
500
|
+
importers: ReadonlyMap<string, readonly Importer[]>;
|
|
501
|
+
reExporters: ReadonlyMap<string, readonly Passer[]>;
|
|
502
|
+
/** the packages holding an import that resolves to no file of the tree */
|
|
503
|
+
unresolvedIn: ReadonlySet<string>;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
interface BuildGraphParams {
|
|
507
|
+
files: ReadonlyMap<string, ModuleSummary>;
|
|
508
|
+
resolve: (from: string, specifier: string) => Resolution;
|
|
509
|
+
/** the directory of the package holding a file, `.` for the root's */
|
|
510
|
+
packageOfFile: (path: string) => string;
|
|
511
|
+
/** the packages holding a changed TypeScript file */
|
|
512
|
+
changedPackages: ReadonlySet<string>;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const buildGraph = ({
|
|
516
|
+
files,
|
|
517
|
+
resolve,
|
|
518
|
+
packageOfFile,
|
|
519
|
+
changedPackages,
|
|
520
|
+
}: BuildGraphParams): ReverseGraph => {
|
|
521
|
+
const importers = new Map<string, Importer[]>();
|
|
522
|
+
const reExporters = new Map<string, Passer[]>();
|
|
523
|
+
const unresolvedIn = new Set<string>();
|
|
524
|
+
const push = <Item>(
|
|
525
|
+
map: Map<string, Item[]>,
|
|
526
|
+
key: string,
|
|
527
|
+
item: Item,
|
|
528
|
+
): void => {
|
|
529
|
+
const items = map.get(key);
|
|
530
|
+
if (items === undefined) map.set(key, [item]);
|
|
531
|
+
else items.push(item);
|
|
532
|
+
};
|
|
533
|
+
for (const [path, { imports, reExports }] of files) {
|
|
534
|
+
const reach = (specifier: string): string | null => {
|
|
535
|
+
const resolution = resolve(path, specifier);
|
|
536
|
+
const importerPackage = packageOfFile(path);
|
|
537
|
+
if (
|
|
538
|
+
(resolution.kind === "alias" && changedPackages.has(importerPackage)) ||
|
|
539
|
+
(resolution.kind === "workspaceMiss" &&
|
|
540
|
+
changedPackages.has(
|
|
541
|
+
resolution.directory === "" ? "." : resolution.directory,
|
|
542
|
+
))
|
|
543
|
+
) {
|
|
544
|
+
unresolvedIn.add(importerPackage);
|
|
545
|
+
}
|
|
546
|
+
return resolution.kind === "file" ? resolution.path : null;
|
|
547
|
+
};
|
|
548
|
+
for (const { specifier, names } of imports) {
|
|
549
|
+
const target = reach(specifier);
|
|
550
|
+
if (target !== null) push(importers, target, { from: path, names });
|
|
551
|
+
}
|
|
552
|
+
for (const { specifier, local, exported } of reExports) {
|
|
553
|
+
const target = reach(specifier);
|
|
554
|
+
if (target !== null) {
|
|
555
|
+
push(reExporters, target, { from: path, local, exported });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return { importers, reExporters, unresolvedIn };
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The files using `name` of `file`, directly or through the modules passing it
|
|
564
|
+
* on (a barrel, with its renames), which are followed and not listed.
|
|
565
|
+
*/
|
|
566
|
+
const importersOf = (
|
|
567
|
+
{ importers, reExporters }: ReverseGraph,
|
|
568
|
+
file: string,
|
|
569
|
+
name: string,
|
|
570
|
+
): Set<string> => {
|
|
571
|
+
const found = new Set<string>();
|
|
572
|
+
const visited = new Set([`${file}\0${name}`]);
|
|
573
|
+
const stack: [string, string][] = [[file, name]];
|
|
574
|
+
while (stack.length > 0) {
|
|
575
|
+
const [current, currentName] = stack.pop()!;
|
|
576
|
+
for (const { from, names } of importers.get(current) ?? []) {
|
|
577
|
+
if (names.includes(currentName) || names.includes("*")) found.add(from);
|
|
578
|
+
}
|
|
579
|
+
for (const { from, local, exported } of reExporters.get(current) ?? []) {
|
|
580
|
+
const passesAll = local === "*" && exported === "*";
|
|
581
|
+
if (local !== "*" && local !== currentName) continue;
|
|
582
|
+
if (passesAll && currentName === "default") continue;
|
|
583
|
+
const next: [string, string] = [
|
|
584
|
+
from,
|
|
585
|
+
exported === "*" ? currentName : exported,
|
|
586
|
+
];
|
|
587
|
+
const key = `${next[0]}\0${next[1]}`;
|
|
588
|
+
if (visited.has(key)) continue;
|
|
589
|
+
visited.add(key);
|
|
590
|
+
stack.push(next);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
found.delete(file);
|
|
594
|
+
return found;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
export interface ImpactsRequest {
|
|
598
|
+
/** the registered checkout, resolved by the caller */
|
|
599
|
+
checkout: string;
|
|
600
|
+
head: string;
|
|
601
|
+
base: string;
|
|
602
|
+
/** kept by the caller between calls: stepping through commits parses only the blobs that differ */
|
|
603
|
+
cache: BindingsCache;
|
|
604
|
+
/** `maxTypescriptFiles` unless set */
|
|
605
|
+
maxFiles?: number;
|
|
606
|
+
/** `maxReadMs` unless set */
|
|
607
|
+
maxReadMs?: number;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* For the range `readReadingOrder` reads, the exports the change touches and
|
|
612
|
+
* the files outside the diff using them, across the workspace packages. Read
|
|
613
|
+
* at `head`, plus the changed files at the merge base, to know what was
|
|
614
|
+
* exported before. The commits are fetched by sha when the checkout lacks them.
|
|
615
|
+
*
|
|
616
|
+
* Never says zero when it does not know: a specifier resolving to no file of
|
|
617
|
+
* the tree (a `paths` alias, a `types` pointing at a build output) is reported
|
|
618
|
+
* as unresolved by package, and what is not TypeScript is `notAnalyzed`.
|
|
619
|
+
*/
|
|
620
|
+
export const readImpacts = async ({
|
|
621
|
+
checkout,
|
|
622
|
+
head,
|
|
623
|
+
base,
|
|
624
|
+
cache,
|
|
625
|
+
maxFiles = maxTypescriptFiles,
|
|
626
|
+
maxReadMs: budgetMs = maxReadMs,
|
|
627
|
+
}: ImpactsRequest): Promise<DiffImpactsResponse> => {
|
|
628
|
+
if (!isSha(head) || !isSha(base)) {
|
|
629
|
+
throw new Error("head and base must be commit shas");
|
|
630
|
+
}
|
|
631
|
+
await ensureCommits(checkout, [head, base], undefined);
|
|
632
|
+
const mergeBaseOutput = await git(checkout, ["merge-base", base, head]);
|
|
633
|
+
const mergeBase = mergeBaseOutput.trim();
|
|
634
|
+
const numstat = await readNumstat(checkout, [mergeBase, head], []);
|
|
635
|
+
|
|
636
|
+
const blobs = await listBlobs(checkout, head);
|
|
637
|
+
const treePaths = blobs.map(({ path }) => path);
|
|
638
|
+
const inTree = new Set(treePaths);
|
|
639
|
+
const typescriptBlobs = blobs.filter(
|
|
640
|
+
({ path }) => isTypescriptPath(path) && !isInNodeModules(path),
|
|
641
|
+
);
|
|
642
|
+
if (typescriptBlobs.length > maxFiles) {
|
|
643
|
+
return { tooLarge: true, reason: "files" };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const summaries = await readSummaries({
|
|
647
|
+
checkout,
|
|
648
|
+
head,
|
|
649
|
+
blobs: typescriptBlobs,
|
|
650
|
+
cache,
|
|
651
|
+
budgetMs,
|
|
652
|
+
});
|
|
653
|
+
if (summaries === null) return { tooLarge: true, reason: "time" };
|
|
654
|
+
|
|
655
|
+
const manifests = await readManifests(checkout, head, treePaths);
|
|
656
|
+
const files = new Map(
|
|
657
|
+
typescriptBlobs.map(({ path, oid }) => [path, summaries.get(oid)!]),
|
|
658
|
+
);
|
|
659
|
+
const packageOfFile = (path: string): string =>
|
|
660
|
+
manifestDirectoryOf(path, manifests) || ".";
|
|
661
|
+
const analyzed = numstat.filter(({ path }) => files.has(path));
|
|
662
|
+
const resolve = createResolver({
|
|
663
|
+
exists: (path) => inTree.has(path),
|
|
664
|
+
manifests,
|
|
665
|
+
});
|
|
666
|
+
const resolveFile = (from: string, specifier: string): string | null => {
|
|
667
|
+
const resolution = resolve(from, specifier);
|
|
668
|
+
return resolution.kind === "file" ? resolution.path : null;
|
|
669
|
+
};
|
|
670
|
+
const graph = buildGraph({
|
|
671
|
+
files,
|
|
672
|
+
resolve,
|
|
673
|
+
packageOfFile,
|
|
674
|
+
changedPackages: new Set(analyzed.map(({ path }) => packageOfFile(path))),
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const changedSet = new Set(numstat.map(({ path }) => path));
|
|
678
|
+
const notAnalyzed = numstat
|
|
679
|
+
.filter(({ path }) => !files.has(path))
|
|
680
|
+
.map(({ path }) => path);
|
|
681
|
+
|
|
682
|
+
const touchedLines = await readTouchedLines({
|
|
683
|
+
checkout,
|
|
684
|
+
mergeBase,
|
|
685
|
+
head,
|
|
686
|
+
entries: analyzed,
|
|
687
|
+
});
|
|
688
|
+
const sources = await catFiles(
|
|
689
|
+
checkout,
|
|
690
|
+
head,
|
|
691
|
+
analyzed.map(({ path }) => path),
|
|
692
|
+
);
|
|
693
|
+
const baseSources = await catFiles(
|
|
694
|
+
checkout,
|
|
695
|
+
mergeBase,
|
|
696
|
+
analyzed.map(({ paths }) => paths[0]!),
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
const impacts: DiffFileImpacts[] = [];
|
|
700
|
+
for (const { path, paths } of analyzed) {
|
|
701
|
+
const source = sources.get(path);
|
|
702
|
+
if (source === undefined) {
|
|
703
|
+
notAnalyzed.push(path);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
const baseSource = baseSources.get(paths[0]!);
|
|
707
|
+
const baseBindings =
|
|
708
|
+
baseSource === undefined ? undefined : exportedBindings(path, baseSource);
|
|
709
|
+
const { touched, removed, touchedStars, removedStars } = touchedExports({
|
|
710
|
+
path,
|
|
711
|
+
source,
|
|
712
|
+
touchedLines: touchedLines.get(path) ?? [],
|
|
713
|
+
baseExports:
|
|
714
|
+
baseBindings === undefined ? [] : exportedNames(baseBindings),
|
|
715
|
+
baseStars:
|
|
716
|
+
baseBindings === undefined
|
|
717
|
+
? []
|
|
718
|
+
: starSpecifiers(baseBindings.reExports),
|
|
719
|
+
});
|
|
720
|
+
let starsUnknown = false;
|
|
721
|
+
const known = (names: string[] | null): string[] => {
|
|
722
|
+
if (names === null) starsUnknown = true;
|
|
723
|
+
return names ?? [];
|
|
724
|
+
};
|
|
725
|
+
const starNamesAtHead = (specifier: string): string[] =>
|
|
726
|
+
known(
|
|
727
|
+
starNames({
|
|
728
|
+
from: path,
|
|
729
|
+
specifier,
|
|
730
|
+
summaryOf: (target) => files.get(target),
|
|
731
|
+
resolveFile,
|
|
732
|
+
}),
|
|
733
|
+
);
|
|
734
|
+
const baseSummaries =
|
|
735
|
+
removedStars.length === 0
|
|
736
|
+
? new Map<string, ModuleSummary>()
|
|
737
|
+
: await readSummariesAt({
|
|
738
|
+
checkout,
|
|
739
|
+
ref: mergeBase,
|
|
740
|
+
roots: removedStars.flatMap(
|
|
741
|
+
(specifier) => resolveFile(paths[0]!, specifier) ?? [],
|
|
742
|
+
),
|
|
743
|
+
resolveFile,
|
|
744
|
+
});
|
|
745
|
+
const removedByStars = removedStars.flatMap((specifier) =>
|
|
746
|
+
known(
|
|
747
|
+
starNames({
|
|
748
|
+
from: paths[0]!,
|
|
749
|
+
specifier,
|
|
750
|
+
summaryOf: (target) => baseSummaries.get(target),
|
|
751
|
+
resolveFile,
|
|
752
|
+
}),
|
|
753
|
+
),
|
|
754
|
+
);
|
|
755
|
+
const headSummary = files.get(path)!;
|
|
756
|
+
const stillExported = new Set([
|
|
757
|
+
...headSummary.exports,
|
|
758
|
+
...(removed.length + removedByStars.length === 0
|
|
759
|
+
? []
|
|
760
|
+
: starSpecifiers(headSummary.reExports).flatMap(starNamesAtHead)),
|
|
761
|
+
]);
|
|
762
|
+
const touchedNames = new Set([
|
|
763
|
+
...touched,
|
|
764
|
+
...touchedStars.flatMap(starNamesAtHead),
|
|
765
|
+
]);
|
|
766
|
+
const removedNames = new Set(
|
|
767
|
+
[...removed, ...removedByStars].filter(
|
|
768
|
+
(name) => !stillExported.has(name),
|
|
769
|
+
),
|
|
770
|
+
);
|
|
771
|
+
if (starsUnknown && !notAnalyzed.includes(path)) notAnalyzed.push(path);
|
|
772
|
+
const exports: DiffExportImpact[] = [
|
|
773
|
+
...[...touchedNames].map((name) => ({ name, removed: false })),
|
|
774
|
+
...[...removedNames].map((name) => ({ name, removed: true })),
|
|
775
|
+
].flatMap(({ name, removed: isRemoved }) => {
|
|
776
|
+
const importers = [...importersOf(graph, path, name)]
|
|
777
|
+
.filter((importer) => !changedSet.has(importer))
|
|
778
|
+
.toSorted((left, right) => left.localeCompare(right))
|
|
779
|
+
.map((importer) => ({
|
|
780
|
+
path: importer,
|
|
781
|
+
test: isAttachablePath(importer),
|
|
782
|
+
}));
|
|
783
|
+
return importers.length === 0
|
|
784
|
+
? []
|
|
785
|
+
: [{ name, removed: isRemoved, importers }];
|
|
786
|
+
});
|
|
787
|
+
if (exports.length > 0) impacts.push({ path, exports });
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
tooLarge: false,
|
|
791
|
+
files: impacts,
|
|
792
|
+
notAnalyzed,
|
|
793
|
+
unresolvedIn: [...graph.unresolvedIn].toSorted((left, right) =>
|
|
794
|
+
left.localeCompare(right),
|
|
795
|
+
),
|
|
796
|
+
};
|
|
797
|
+
};
|