@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.
Files changed (50) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +86 -2
  3. package/build/files-node.mjs +70 -0
  4. package/build/git-node.mjs +1792 -0
  5. package/build/index-browser.es.js +3338 -0
  6. package/build/index-node.mjs +3338 -0
  7. package/package.json +136 -4
  8. package/src/api/pullRequest.ts +617 -0
  9. package/src/config/insertCheckoutEntry.ts +54 -0
  10. package/src/diff/diffPaths.ts +78 -0
  11. package/src/diff/impacts.ts +47 -0
  12. package/src/diff/readingOrder.ts +428 -0
  13. package/src/diff/themes.ts +105 -0
  14. package/src/diff/types.ts +141 -0
  15. package/src/files/editableFile.ts +163 -0
  16. package/src/files.ts +13 -0
  17. package/src/git/groupDiffPaths.ts +134 -0
  18. package/src/git/manifests.ts +96 -0
  19. package/src/git/moduleBindings.ts +571 -0
  20. package/src/git/pullRequestDiff.ts +159 -0
  21. package/src/git/readBranchDiff.ts +359 -0
  22. package/src/git/readImpacts.ts +797 -0
  23. package/src/git/readingOrder.ts +190 -0
  24. package/src/git/run.ts +213 -0
  25. package/src/git.ts +38 -0
  26. package/src/github/applySuggestions.ts +399 -0
  27. package/src/github/editPullRequest.ts +790 -0
  28. package/src/github/fetchCompareDiff.ts +21 -0
  29. package/src/github/imageProxies.ts +43 -0
  30. package/src/github/lineDiff.ts +145 -0
  31. package/src/github/prQuery.ts +298 -0
  32. package/src/github/pullRequestDetail.ts +1347 -0
  33. package/src/github/pullRequestHead.ts +65 -0
  34. package/src/github/pullRequestRevisions.ts +124 -0
  35. package/src/github/transport.ts +171 -0
  36. package/src/index.ts +35 -0
  37. package/src/reviewflow/board.ts +333 -0
  38. package/src/reviewflow/changedParts.ts +80 -0
  39. package/src/reviewflow/checksFromReviewflow.ts +75 -0
  40. package/src/reviewflow/client.ts +589 -0
  41. package/src/reviewflow/follow.ts +345 -0
  42. package/src/reviewflow/status.ts +17 -0
  43. package/src/reviewflow/stream.ts +199 -0
  44. package/src/types/entities.ts +8 -0
  45. package/src/types/ids.ts +1 -0
  46. package/src/types/reviewflow.ts +32 -0
  47. package/src/utils/pullRequestEdit.ts +108 -0
  48. package/src/utils/pullRequestPart.ts +12 -0
  49. package/src/utils/pullRequestReaction.ts +19 -0
  50. 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
+ };