@preventive/triage 1.0.0-alpha.45 → 1.0.0-alpha.46

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 (79) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  7. package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
  8. package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
  9. package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
  10. package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
  11. package/out/chunk-G24A42V6.js +2 -0
  12. package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
  13. package/out/chunk-LE5ZWLG4.js +235 -0
  14. package/out/chunk-MSION3G4.js +36 -0
  15. package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
  16. package/out/chunk-V3KWNNMY.js +1 -0
  17. package/out/client-managed-import.js +1 -1
  18. package/out/client-managed.js +25 -25
  19. package/out/compare.js +13 -13
  20. package/out/dialogs.js +40 -40
  21. package/out/graph.js +1 -1
  22. package/out/index.html +6 -6
  23. package/out/scan.js +2 -2
  24. package/out/terminal.js +1 -1
  25. package/out/view.css +1 -1
  26. package/out/view.js +15 -15
  27. package/package.json +3 -1
  28. package/risk/actions/findings.js +14 -9
  29. package/risk/actions/scripts/shell.js +5 -1
  30. package/risk/index.d.ts +5 -2
  31. package/server-e2e/app.ts +5 -4
  32. package/server-e2e/http.ts +5 -2
  33. package/server-managed/bundle-cache.ts +7 -1
  34. package/server-managed/combined.ts +3 -2
  35. package/server-managed/commit-lockfiles.ts +21 -11
  36. package/server-managed/config.ts +6 -0
  37. package/server-managed/db-methods.ts +37 -9
  38. package/server-managed/db-neon.ts +61 -21
  39. package/server-managed/db-schema.ts +4 -0
  40. package/server-managed/db.ts +2 -1
  41. package/server-managed/dependency-reviews.ts +13 -3
  42. package/server-managed/github-app.ts +12 -6
  43. package/server-managed/github-issue-links.ts +11 -5
  44. package/server-managed/github-pulls.ts +2 -2
  45. package/server-managed/github-requests.ts +39 -0
  46. package/server-managed/http-response.ts +10 -2
  47. package/server-managed/http.ts +195 -111
  48. package/server-managed/index.ts +8 -7
  49. package/server-managed/kept-briefly.ts +37 -0
  50. package/server-managed/npm-delta.ts +11 -6
  51. package/server-managed/npm-kept.ts +32 -2
  52. package/server-managed/npm-loads.ts +100 -30
  53. package/server-managed/npm-packages.ts +89 -3
  54. package/server-managed/npm-risk.ts +4 -1
  55. package/server-managed/pretty-print.ts +67 -23
  56. package/server-managed/pull-dependencies.ts +29 -9
  57. package/server-managed/pull-dependency-reviews.ts +8 -8
  58. package/server-managed/queue.ts +16 -0
  59. package/server-managed/rename-diff.ts +22 -13
  60. package/server-managed/report-sources.ts +1 -1
  61. package/server-managed/report-store.ts +42 -9
  62. package/server-managed/repository-actions.ts +15 -6
  63. package/server-managed/repository-browser.ts +119 -31
  64. package/server-managed/repository-dependency-reviews.ts +41 -19
  65. package/server-managed/repository-history.ts +24 -8
  66. package/server-managed/repository-pulse.ts +8 -1
  67. package/server-managed/repository-risk-audit.ts +13 -0
  68. package/server-managed/repository-risk-read.ts +11 -6
  69. package/server-managed/repository-risk.ts +27 -20
  70. package/server-managed/session.ts +18 -4
  71. package/server-managed/sql.ts +5 -2
  72. package/server-managed/storage.ts +4 -2
  73. package/server-managed/team-reports.ts +24 -12
  74. package/server-managed/upstream-cache.ts +12 -0
  75. package/server.ts +14 -5
  76. package/out/chunk-5DHL5NG3.js +0 -2
  77. package/out/chunk-NV7ES4EW.js +0 -1
  78. package/out/chunk-PTGDYLNS.js +0 -36
  79. package/out/chunk-QRD6GIG5.js +0 -229
@@ -1,6 +1,7 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import type { ManagedConfig } from './config.ts'
2
3
  import type { SelectedRepo } from './db.ts'
3
- import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
4
+ import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, onTokenRejected, repoAccessToken } from './github-app.ts'
4
5
  import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
5
6
  import { readSolidityEntryPoints } from './solidity-entry-points.ts'
6
7
  import { parseGithubCommit } from './bundle-commits.ts'
@@ -40,14 +41,63 @@ function credentialFailure(err: unknown): err is GithubApiError {
40
41
  return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
41
42
  }
42
43
 
43
- // Create once per HTTP request. Coalesce concurrent credential lookups without
44
- // caching repository visibility or permissions, or sharing credentials across users.
44
+ // GitHub's answer on a user's access to a repository, as checkAccess reads it:
45
+ // the repository's metadata, which credential reads it (the user's token, the
46
+ // App installation's, or none, as for a public repository read anonymously)
47
+ // and the permission level GitHub told, where it was asked. Kept per user, by
48
+ // their GitHub id and token, and per repository, by its id, name and
49
+ // installation, for config.repositoryAccessCacheMs; only access granted is kept.
50
+ // One answered for a user with a token is kept no longer once GitHub rejects
51
+ // that token (`userToken`, hashed), whichever credential reads it: a private
52
+ // repository's was granted on who that token told GitHub they are. The next
53
+ // check asks GitHub, falling back to the installation's or none as it does.
54
+ type RepoMetadata = { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
55
+ type AccessVerdict = { until: number; data: RepoMetadata; credential: 'user' | 'app' | 'none'; level: string | null; userToken: string | null }
56
+ type VerdictCache = { get(): AccessVerdict | undefined; set(verdict: Omit<AccessVerdict, 'until' | 'userToken'>): void }
57
+ const MAX_ACCESS_VERDICTS = 1000
58
+ // By the fetch reading GitHub, as installation tokens are: a test's own.
59
+ const accessVerdicts = new WeakMap<typeof fetch, Map<string, AccessVerdict>>()
60
+ const hashed = (token: string) => createHash('sha256').update(token).digest('hex')
61
+ onTokenRejected((token, fetchImpl) => {
62
+ const kept = accessVerdicts.get(fetchImpl)
63
+ if (!kept) return
64
+ const rejected = hashed(token)
65
+ for (const [key, verdict] of kept) if (verdict.userToken === rejected) kept.delete(key)
66
+ })
67
+
68
+ function verdictCache(config: ManagedConfig, user: RepositoryBrowserUser, repo: SelectedRepo, fetchImpl: typeof fetch): VerdictCache | null {
69
+ const keepMs = config.repositoryAccessCacheMs
70
+ if (!(keepMs > 0)) return null
71
+ let verdicts = accessVerdicts.get(fetchImpl)
72
+ if (!verdicts) { verdicts = new Map(); accessVerdicts.set(fetchImpl, verdicts) }
73
+ const kept = verdicts
74
+ // The token hashed with the rest, never kept as it is.
75
+ const key = createHash('sha256').update(JSON.stringify([user.githubUserId, user.token, repo.repoId, repo.fullName.toLowerCase(), repo.installationId, config.githubAppId])).digest('hex')
76
+ return {
77
+ get() {
78
+ const verdict = kept.get(key)
79
+ if (verdict && verdict.until > Date.now()) return verdict
80
+ kept.delete(key)
81
+ return undefined
82
+ },
83
+ set(verdict) {
84
+ kept.delete(key)
85
+ kept.set(key, { ...verdict, until: Date.now() + keepMs, userToken: user.token ? hashed(user.token) : null })
86
+ while (kept.size > MAX_ACCESS_VERDICTS) kept.delete(kept.keys().next().value!)
87
+ },
88
+ }
89
+ }
90
+
91
+ // Create once per HTTP request. Coalesce concurrent credential lookups, and
92
+ // keep GitHub's answer on each user's access to each repository a while
93
+ // (verdictCache), never sharing credentials or answers across users.
45
94
  export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
46
95
  const tokens = new Map<number, Promise<string | null>>()
47
96
  let identity: Promise<GithubIdentity> | undefined
48
- const getIdentity = (fresh = false): Promise<GithubIdentity> => {
97
+ // The user as GitHub tells them, asked once a request.
98
+ const getIdentity = (): Promise<GithubIdentity> => {
49
99
  if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
50
- if (!identity || fresh) {
100
+ if (!identity) {
51
101
  identity = githubUserIdentity(user.token, fetchImpl).then(value => {
52
102
  if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
53
103
  return value
@@ -71,7 +121,7 @@ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryB
71
121
  // Its Dependabot alerts, with the token of the installation GitHub tells
72
122
  // covers it, where the user's own access lets them see them
73
123
  // (dependabot-access.ts).
74
- return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl).then(reader => ({ ...reader,
124
+ return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl, verdictCache(config, user, repo, fetchImpl)).then(reader => ({ ...reader,
75
125
  dependabotAlerts: () => appDependabotAlerts(config, repo, { identity: getIdentity, permission: reader.permission }, fetchImpl) }))
76
126
  },
77
127
  }
@@ -96,48 +146,86 @@ export async function repositoryTreeFiles(fullName: string, token: string | null
96
146
  return { files, bytes }
97
147
  }
98
148
 
99
- async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
149
+ type Access = { data: RepoMetadata; token: string | null; level: string | null }
150
+
151
+ // GitHub asked whether the user may read `repo`: its metadata, read with
152
+ // `token` (the reader's credential so far), else the installation's, else
153
+ // none; and of one not public, the user's permission, read with the
154
+ // installation's. What it told, the token reading it, and the level asked.
155
+ async function askAccess(repo: SelectedRepo, start: string | null, userToken: string | null, installationToken: () => Promise<string | null>,
156
+ getIdentity: () => Promise<GithubIdentity>, fetchImpl: typeof fetch): Promise<Access> {
100
157
  const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
101
- // Public reads can use the signed-in user's quota without repository grants.
102
- // Nonpublic reads still require the repository App and the effective-user gate,
103
- // whose permission level is kept (`level`).
104
- let level: string | null = null, token = userToken ?? await installationToken()
105
- const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
158
+ let token = start
106
159
  const metadata = async () => {
107
- try { return await read('') }
160
+ try { return await githubJson(base, token, fetchImpl) }
108
161
  catch (err) {
109
162
  if (!token || !credentialFailure(err)) throw err
110
163
  const fallback = await installationToken()
111
164
  if (fallback && fallback !== token) {
112
165
  token = fallback
113
- try { return await read('') } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
166
+ try { return await githubJson(base, token, fetchImpl) } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
114
167
  }
115
168
  // Anonymous fallback is only for unavailable credentials, never rate
116
169
  // limits, network errors, or GitHub outages.
117
170
  token = null
118
- return read('')
171
+ return githubJson(base, token, fetchImpl)
119
172
  }
120
173
  }
121
- const checkAccess = async (freshIdentity = false) => {
122
- // Stored visibility can be stale (or internal with private=false). Require
123
- // current, explicit public visibility or the caller's effective permission.
124
- const data = await metadata() as { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
125
- if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
126
- throw new GithubApiError(404, 'no-repository')
127
- }
128
- if (data.private === false && data.visibility === 'public') return data
129
- if (!token) throw new GithubApiError(404, 'no-repository')
130
- const appToken = await installationToken()
131
- if (!appToken) throw new GithubApiError(404, 'no-repository')
132
- level = await githubRepoPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl)
133
- if (!READ_PERMISSIONS.has(level)) throw new GithubApiError(404, 'no-repository')
134
- token = appToken
135
- return data
174
+ // A repository last known private asks who the user is as it asks for its
175
+ // metadata, not after: its permission is asked of them.
176
+ if (repo.private && userToken) getIdentity().catch(() => {})
177
+ // Stored visibility can be stale (or internal with private=false). Require
178
+ // current, explicit public visibility or the caller's effective permission.
179
+ const data = await metadata() as RepoMetadata
180
+ if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
181
+ throw new GithubApiError(404, 'no-repository')
182
+ }
183
+ if (data.private === false && data.visibility === 'public') return { data, token, level: null }
184
+ if (!token) throw new GithubApiError(404, 'no-repository')
185
+ const appToken = await installationToken()
186
+ if (!appToken) throw new GithubApiError(404, 'no-repository')
187
+ const level = await githubRepoPermission(repo.fullName, appToken, await getIdentity(), fetchImpl)
188
+ if (!READ_PERMISSIONS.has(level)) throw new GithubApiError(404, 'no-repository')
189
+ return { data, token: appToken, level }
190
+ }
191
+
192
+ // GitHub's answer as kept (verdictCache), with the credential it chose,
193
+ // where that credential is still had; else asked (`ask`), and kept where it
194
+ // grants access.
195
+ async function keptAccess(verdicts: VerdictCache | null, userToken: string | null, installationToken: () => Promise<string | null>,
196
+ ask: () => Promise<Access>): Promise<Access> {
197
+ const kept = verdicts?.get()
198
+ if (kept) {
199
+ const credential = kept.credential === 'user' ? userToken : kept.credential === 'app' ? await installationToken() : null
200
+ if (kept.credential === 'none' || credential) return { data: kept.data, token: credential, level: kept.level }
201
+ }
202
+ const asked = await ask()
203
+ const { id, full_name, private: hidden, visibility, default_branch } = asked.data
204
+ const data = { id, full_name, private: hidden, visibility, default_branch }
205
+ verdicts?.set({ data, credential: asked.token === null ? 'none' : asked.token === userToken ? 'user' : 'app', level: asked.level })
206
+ return { ...asked, data }
207
+ }
208
+
209
+ async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch,
210
+ verdicts: VerdictCache | null) {
211
+ const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
212
+ // Public reads can use the signed-in user's quota without repository grants.
213
+ // Nonpublic reads still require the repository App and the effective-user gate,
214
+ // whose permission level is kept (`level`).
215
+ let level: string | null = null, token = userToken ?? await installationToken()
216
+ const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
217
+ const checkAccess = async (): Promise<RepoMetadata> => {
218
+ const access = await keptAccess(verdicts, userToken, installationToken, () => askAccess(repo, token, userToken, installationToken, getIdentity, fetchImpl))
219
+ token = access.token
220
+ level = access.level
221
+ return access.data
136
222
  }
137
223
  const current = await checkAccess()
138
224
  const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
139
225
  return {
140
- recheckAccess: () => checkAccess(true),
226
+ // Its visibility and the user's permission asked again, the user as
227
+ // GitHub told them earlier in the request, not asked again.
228
+ recheckAccess: () => checkAccess(),
141
229
  // Server-only credential, selected by the same live visibility/permission
142
230
  // gate as directory browsing. Never included in an HTTP response.
143
231
  readToken: () => token,
@@ -38,8 +38,13 @@ export interface PublishedReview {
38
38
  // published reviews of the packages asked.
39
39
  export interface RepoReviewState extends Omit<RepoDependencyReview, 'enabled'> { canReview: boolean; reviews: PublishedReview[] }
40
40
 
41
+ // A package version whose published reviews are asked.
42
+ export interface ReviewedVersionKey { name: string; version: string }
43
+
41
44
  export interface RepositoryDependencyReviewStore {
42
45
  listRepoDependencyReviews(): Promise<Map<number, RepoDependencyReview>>
46
+ // One repository's, none where it isn't connected.
47
+ repoDependencyReview(repoId: number): Promise<RepoDependencyReview | null>
43
48
  // The teams whose published reviews count for each repository (COUNTING_TEAM),
44
49
  // by name.
45
50
  listRepoReviewTeams(): Promise<Map<number, { id: string; name: string }[]>>
@@ -47,7 +52,9 @@ export interface RepositoryDependencyReviewStore {
47
52
  // `since` or `lockfiles` is undefined, which keeps it; the setting then, or
48
53
  // null for no repository.
49
54
  setRepoDependencyReview(repoId: number, setting: Omit<RepoDependencyReview, 'since' | 'lockfiles'> & { since?: string | null; lockfiles?: LockfileScope }): Promise<RepoDependencyReview | null>
50
- repoDependencyReviewState(repoId: number, user: string, packages: string[]): Promise<RepoReviewState | null>
55
+ // Its review in effect for `user`, as `setting` tells it where read
56
+ // already, with the published reviews of `versions`.
57
+ repoDependencyReviewState(repoId: number, user: string, versions: ReviewedVersionKey[], setting?: RepoDependencyReview | null): Promise<RepoReviewState | null>
51
58
  // Which of `users` publish reviews to a team counting for repository
52
59
  // `repoId`, whose reviews its pull requests' comments make count.
53
60
  listRepoReviewers(repoId: number, users: string[]): Promise<Set<string>>
@@ -59,33 +66,42 @@ const setting = (row: SettingRow): RepoDependencyReview => ({ enabled: row.enabl
59
66
  // dependencies and have it, not hidden.
60
67
  const COUNTING_TEAM = `t.hidden = 0 AND t.dependency_review = 1 AND EXISTS (
61
68
  SELECT 1 FROM managed_team_repo tr WHERE tr.team_id = t.id AND tr.repo_id = ?)`
62
- const PACKAGES_AT_ONCE = 500
69
+ const VERSIONS_AT_ONCE = 250
63
70
 
64
- // The published and unrevoked reviews of `packages` in teams counting for
65
- // repository `repoId`, each with whether it is full, a few hundred packages
66
- // a query.
67
- async function publishedReviews(db: ManagedSql, repoId: number, packages: string[]): Promise<PublishedReview[]> {
71
+ // The published and unrevoked reviews of `versions` in teams counting for
72
+ // repository `repoId`, each with whether it is full, a couple of hundred
73
+ // versions a query: those of their packages' and versions' found, the
74
+ // versions asked alone kept, their files' reviews read of those alone.
75
+ async function publishedReviews(db: ManagedSql, repoId: number, versions: ReviewedVersionKey[]): Promise<PublishedReview[]> {
68
76
  const found: PublishedReview[] = []
69
- for (let at = 0; at < packages.length; at += PACKAGES_AT_ONCE) {
70
- const names = packages.slice(at, at + PACKAGES_AT_ONCE)
71
- const rows = await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
77
+ for (let at = 0; at < versions.length; at += VERSIONS_AT_ONCE) {
78
+ const asked = versions.slice(at, at + VERSIONS_AT_ONCE)
79
+ const keys = new Set(asked.map(({ name, version }) => `${name}@${version}`))
80
+ const [names, numbers] = [[...new Set(asked.map(({ name }) => name))], [...new Set(asked.map(({ version }) => version))]]
81
+ const rows = (await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
72
82
  NULLIF(r.base_package, '') AS basePackage, NULLIF(r.base_version, '') AS baseVersion, r.files, r.status, r.published_at AS publishedAt
73
83
  FROM managed_dependency_review r
74
84
  WHERE r.ecosystem = 'npm' AND r.published_at IS NOT NULL AND r.revoked_at IS NULL AND r.status IS NOT NULL
75
- AND r.package IN (${names.map(() => '?').join(',')})
85
+ AND r.package IN (${names.map(() => '?').join(',')}) AND r.version IN (${numbers.map(() => '?').join(',')})
76
86
  AND EXISTS (SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id
77
- WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, repoId) as
87
+ WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, ...numbers, repoId) as
78
88
  { id: string; userId: string | null; userLogin: string; package: string; version: string; integrity: string
79
- basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[]
89
+ basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[])
90
+ .filter(row => keys.has(`${row.package}@${row.version}`))
80
91
  if (rows.length === 0) continue
81
- const entries = await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
82
- WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]
92
+ const entries = new Map<string, { file: string; status: ReviewVerdict | null }[]>()
93
+ for (const { reviewId, ...entry } of await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
94
+ WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]) {
95
+ const of = entries.get(reviewId)
96
+ if (of) of.push(entry)
97
+ else entries.set(reviewId, [entry])
98
+ }
83
99
  for (const row of rows) {
84
100
  const snapshot = row.files === null ? null : JSON.parse(row.files) as string[] | { paths: string[]; nonTextFiles: string[] }
85
101
  // A snapshot of paths alone, before non-text files were told, can't be full.
86
102
  const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
87
103
  const nonText = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
88
- const full = summarizeReview(entries.filter(entry => entry.reviewId === row.id), files, nonText).coverage === 'full'
104
+ const full = summarizeReview(entries.get(row.id) ?? [], files, nonText).coverage === 'full'
89
105
  found.push({ id: row.id, package: row.package, version: row.version, integrity: row.integrity, basePackage: row.basePackage, baseVersion: row.baseVersion,
90
106
  reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, status: row.status, full, time: Number(row.publishedAt) })
91
107
  }
@@ -96,11 +112,16 @@ async function publishedReviews(db: ManagedSql, repoId: number, packages: string
96
112
  export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDependencyReviewStore {
97
113
  const columns = `repo_id AS repoId, dependency_review AS enabled, dependency_reviews_required AS required, dependency_review_since AS since,
98
114
  dependency_review_lockfiles AS lockfiles`
115
+ const readSetting = async (repoId: number) => {
116
+ const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
117
+ return row ? setting(row) : null
118
+ }
99
119
  return {
100
120
  async listRepoDependencyReviews() {
101
121
  const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo`).all() as SettingRow[]
102
122
  return new Map(rows.map(row => [row.repoId, setting(row)]))
103
123
  },
124
+ repoDependencyReview: readSetting,
104
125
  async listRepoReviewTeams() {
105
126
  const rows = await db.prepare(`SELECT DISTINCT tr.repo_id AS repoId, t.id AS id, t.name AS name FROM managed_team_repo tr
106
127
  JOIN managed_team t ON t.id = tr.team_id WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all() as { repoId: number; id: string; name: string }[]
@@ -123,13 +144,14 @@ export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDep
123
144
  WHERE u.id IN (${users.map(() => '?').join(',')}) AND ${reviewsIn(true)} AND ${COUNTING_TEAM}`).all(...users, repoId) as { id: string }[]
124
145
  return new Set(rows.map(row => row.id))
125
146
  },
126
- async repoDependencyReviewState(repoId, user, packages) {
127
- const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
128
- if (!row || row.enabled !== 1) return null
147
+ async repoDependencyReviewState(repoId, user, versions, read) {
148
+ const now = read === undefined ? await readSetting(repoId) : read
149
+ if (!now?.enabled) return null
129
150
  if (!await db.prepare(`SELECT 1 FROM managed_team t WHERE ${COUNTING_TEAM}`).get(repoId)) return null
130
151
  const canReview = await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ?
131
152
  WHERE ${reviewsIn(true)} AND ${COUNTING_TEAM}`).get(user, repoId) != null
132
- return { required: row.required, since: row.since, lockfiles: storedScope(row.lockfiles), canReview, reviews: await publishedReviews(db, repoId, [...new Set(packages)]) }
153
+ const asked = [...new Map(versions.map(version => [`${version.name}@${version.version}`, version])).values()]
154
+ return { required: now.required, since: now.since, lockfiles: now.lockfiles, canReview, reviews: await publishedReviews(db, repoId, asked) }
133
155
  },
134
156
  }
135
157
  }
@@ -122,7 +122,7 @@ const TIMED_OUT = /\btimeout\b/iu
122
122
  // are logged, those read past too.
123
123
  export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
124
124
  tolerated: (error: GraphqlError) => boolean = () => false) {
125
- const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }) as { data?: unknown; errors?: GraphqlError[] } | null
125
+ const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
126
126
  const all = Array.isArray(body?.errors) ? body.errors : []
127
127
  const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
128
128
  if (unnamed.length > 0) {
@@ -172,6 +172,9 @@ const GLUE_LOOKUP = 50
172
172
  // changes.
173
173
  export interface HistoryPages {
174
174
  read(tops: string[]): Promise<Map<string, HistoryCommit[]>>
175
+ // The pages kept of `first`; where none is, those of `then`, in one read
176
+ // (`first` whether they are `first`'s). Without it, both are read together.
177
+ readFirst?(first: string[], then: string[]): Promise<{ first: boolean; pages: Map<string, HistoryCommit[]> }>
175
178
  write(top: string, commits: HistoryCommit[]): Promise<void>
176
179
  }
177
180
 
@@ -241,8 +244,11 @@ const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node
241
244
 
242
245
  // The history from each of `starts`, a page of each, kept (`pages`), or read
243
246
  // and then kept by its top; glued on to it, the pages kept that start at a
244
- // parent of a commit listed not listed, or at a commit listed, the oldest
245
- // first (where each goes on furthest), GLUE_LOOKUP looked up at a time, and
247
+ // parent of a commit listed not listed, each adding commits none listed has,
248
+ // and, where any of those has none kept and readable, at a commit listed, the
249
+ // oldest first (where each goes on furthest; a page at a commit listed lists
250
+ // mostly commits listed already), GLUE_LOOKUP of each looked up at a time, in
251
+ // one read where none of those parents' is kept, else two, and
246
252
  // those that start where those go on, till HISTORY_GLUED are listed. The
247
253
  // pages from `starts` are listed whole, however many that is (all branches'
248
254
  // first, as many as ALL_BRANCHES of them): the cap is gluing's. Each commit
@@ -259,11 +265,21 @@ export async function historyFrom(fullName: string, token: string | null, starts
259
265
  for (const top of starts) add(kept.get(top) ?? read.get(top) ?? [])
260
266
  const looked = new Set(starts)
261
267
  for (let round = 0; round < MAX_GLUE_ROUNDS && listed.size < HISTORY_GLUED; round++) {
262
- const parents = [...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent)))
263
- const tops = [...new Set([...parents, ...[...listed.keys()].toReversed()])].filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
264
- if (tops.length === 0) break
265
- for (const sha of tops) looked.add(sha)
266
- const glued = await pages.read(tops)
268
+ const parents = [...new Set([...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent) && !looked.has(parent))))].slice(0, GLUE_LOOKUP)
269
+ const within = [...listed.keys()].toReversed().filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
270
+ if (parents.length === 0 && within.length === 0) break
271
+ // Without readFirst, GLUE_LOOKUP of both at once, the parents first.
272
+ const asked = pages.readFirst ? null : [...parents, ...within].slice(0, GLUE_LOOKUP)
273
+ let { first, pages: glued } = asked ? { first: false, pages: await pages.read(asked) } : await pages.readFirst!(parents, within)
274
+ // A parent with no page kept, or none readable (one GitHub's REST API
275
+ // listed, read with GraphQL), its line going on only by a page at a
276
+ // commit listed: those read too, apart, the others' lines notwithstanding.
277
+ if (first && glued.size < parents.length && within.length > 0) {
278
+ glued = new Map([...glued, ...await pages.read(within)])
279
+ first = false
280
+ }
281
+ // Those at commits listed, unread where a parent's answered, are looked up again.
282
+ for (const sha of asked ?? (first ? parents : [...parents, ...within])) looked.add(sha)
267
283
  for (const commit of [...glued.values()].flat()) {
268
284
  if (listed.size >= HISTORY_GLUED) break
269
285
  if (!listed.has(commit.sha)) listed.set(commit.sha, commit)
@@ -14,6 +14,7 @@
14
14
  // issues closed and opened, how many and the latest of each.
15
15
  import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
16
16
  import { GithubApiError, githubJson } from './github-app.ts'
17
+ import { shareGithubRead } from './github-requests.ts'
17
18
  import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
18
19
 
19
20
  export const PULSE_WEEKS = [1, 4, 13, 52]
@@ -99,7 +100,13 @@ const emptyPunch = () => Array.from({ length: 7 }, () => Array.from({ length: 24
99
100
  // One statistic: its rows once GitHub has computed it, 'pending' while it
100
101
  // computes them (202), none for a repository with no commits (204, no body),
101
102
  // 'too-large' where it counts none (lines past 10,000 commits, 422).
102
- async function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
103
+ function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch) {
104
+ // Stats and lines both need contributors. Share by the original transport;
105
+ // the per-call status wrapper below would otherwise give each its own read.
106
+ return shareGithubRead(fetchImpl, JSON.stringify(['statistic', fullName, token, kind]), () => readStatistic(fullName, token, kind, fetchImpl))
107
+ }
108
+
109
+ async function readStatistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
103
110
  let status = 0
104
111
  const answered: typeof fetch = async (input, init) => {
105
112
  const res = await fetchImpl(input, init)
@@ -33,6 +33,19 @@ const AUDIT_FAILED_KEPT_MS = 10 * 60_000
33
33
  // RISK_KEPT_MS, why they couldn't be had within AUDIT_FAILED_KEPT_MS.
34
34
  const isFresh = ({ at, audit }: { at: number; audit: Audit }) => Date.now() - at < (Array.isArray(audit) ? RISK_KEPT_MS : AUDIT_FAILED_KEPT_MS)
35
35
 
36
+ // The audits of lockfiles, each of its `packages` by its `key` (auditLockfile),
37
+ // `inTurn` with others, asked at once: each null where it has none to ask
38
+ // of, or once `signal` aborts. Each is the caller's to tell once it may.
39
+ export function auditLockfiles(source: AuditSource, memory: Lru<{ at: number; audit: Audit }>, signal: AbortSignal,
40
+ lockfiles: { key: string; packages: readonly LockedPackage[] | null }[], inTurn: <T>(work: () => Promise<T>) => Promise<T>): Promise<Audit | null>[] {
41
+ return lockfiles.map(({ key, packages }) => {
42
+ const audit = packages ? inTurn(() => (signal.aborted ? Promise.resolve(null) : auditLockfile(source, memory, signal, key, packages))) : Promise.resolve(null)
43
+ // One no longer waited on, as the scan stopped, fails alone.
44
+ audit.catch(() => {})
45
+ return audit
46
+ })
47
+ }
48
+
36
49
  // The audit of a lockfile's `packages`, by its `key` as kept (`memory`, then
37
50
  // the source's), else asked and kept; null where it locks none to ask of, or
38
51
  // once `signal` aborts.
@@ -16,6 +16,7 @@ import type { DependabotAlert } from './dependabot-alerts.ts'
16
16
  import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
17
17
  import type { Lru } from './lru.ts'
18
18
  import type { KeptStore } from './npm-kept.ts'
19
+ import { inTurns } from './queue.ts'
19
20
  import { type WorkflowsRead, lastingRead, readActions } from './repository-actions.ts'
20
21
  import { type RiskFailure, failureOf } from './repository-risk-assess.ts'
21
22
  import type { RepositoryRiskLine, RepositoryRiskRepo } from './repository-risk.ts'
@@ -28,6 +29,9 @@ const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'
28
29
  // How long the source's `listings` and `lockfiles` are read for: what
29
30
  // GitHub has at a commit or a blob doesn't change, how it is read here may.
30
31
  export const SCAN_READ_KEPT_MS = 24 * 60 * 60_000
32
+ // How many of a repository's lockfiles are read at once: each one GitHub is
33
+ // asked for where it isn't kept, then parsed.
34
+ const LOCKFILES_AT_ONCE = 4
31
35
 
32
36
  export type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
33
37
  export type LockfileRead = { packages: LockedPackage[] } | RiskFailure
@@ -140,8 +144,8 @@ async function readDependabot(opened: OpenedRepository, paths: readonly string[]
140
144
  }
141
145
  }
142
146
 
143
- // A repository read: its lockfiles listed and read, its Dependabot alerts
144
- // asked and its Actions read meanwhile; its line telling why where listing
147
+ // A repository read: its lockfiles listed and read, a few at once, its
148
+ // Dependabot alerts asked and its Actions read meanwhile; its line telling why where listing
145
149
  // failed; null where the scan stopped. A test's
146
150
  // fixture or example its reader refuses (isTestFixture) is left out, as a
147
151
  // test may keep one so on purpose.
@@ -153,11 +157,12 @@ export async function readRepository<R extends RepositoryRiskRepo>(reading: Repo
153
157
  const acting = workflows ? keptRead(reading.caches.actionFiles, reading.source.actionFiles, `${repo.repoId}@${opened.commit}`,
154
158
  () => readActions({ workflows, file: path => opened.file(path) }), lastingRead) : Promise.resolve(null)
155
159
  acting.catch(() => {})
160
+ const inTurn = inTurns(LOCKFILES_AT_ONCE)
161
+ const lines = await Promise.all(listed.lockfiles.map(lockfile => inTurn(() => (reading.signal.aborted ? Promise.resolve(null) : readLockfile(reading, repo.repoId, opened, lockfile)))))
156
162
  const lockfiles: RepositoryRead['lockfiles'] = []
157
- for (const lockfile of listed.lockfiles) {
158
- if (reading.signal.aborted) return null
159
- const line = await readLockfile(reading, repo.repoId, listed.opened, lockfile)
160
- if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
163
+ for (const [at, line] of lines.entries()) {
164
+ if (!line) return null
165
+ if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: listed.lockfiles[at]!.sha, line })
161
166
  }
162
167
  const dependabot = await alerting
163
168
  const actions = await acting
@@ -34,10 +34,10 @@ import { type Lru, lru } from './lru.ts'
34
34
  import type { KeptStore } from './npm-kept.ts'
35
35
  import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
36
36
  import type { LockedPackage } from './locked-packages.ts'
37
- import { type Queue, queue } from './queue.ts'
37
+ import { type Queue, inTurns, queue } from './queue.ts'
38
38
  import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
39
39
  import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
40
- import { type AdvisoryRow, type Audit, type AuditSource, auditLockfile } from './repository-risk-audit.ts'
40
+ import { type AdvisoryRow, type Audit, type AuditSource, auditLockfiles } from './repository-risk-audit.ts'
41
41
  import { type DependabotRead, type ListedLockfile, type LockfileRead, type OpenedRepository, type RepositoryFailure, type RepositoryRead, SCAN_READ_KEPT_MS,
42
42
  readRepository } from './repository-risk-read.ts'
43
43
  import { type DependabotAlert, alertVersions } from './dependabot-alerts.ts'
@@ -157,6 +157,9 @@ export const riskCaches = (): RiskCaches => ({
157
157
  // the one told: each asks GitHub one thing after another (opening it, and
158
158
  // its head) even where all else is kept.
159
159
  const READ_AHEAD = 6
160
+ // How many lockfiles' advisories are asked of npm at once, those of the
161
+ // repositories read ahead as they are read, each told in its turn.
162
+ const AUDITS_AT_ONCE = 4
160
163
  // How long a part's progress, written as it ends (`progress`), is waited on.
161
164
  const PROGRESS_WRITE_MS = 5000
162
165
  // How many kept risk lines are read of the source's `risks` at once.
@@ -180,6 +183,7 @@ interface Scan {
180
183
  // The repositories told whole, this part or one before (ScanProgress).
181
184
  told: Map<number, ToldRepository>
182
185
  actions: ReturnType<typeof resolvingActions> | null
186
+ audits: ReturnType<typeof inTurns>
183
187
  }
184
188
 
185
189
  // A package as a scan meets it: by its name, version and a mirror's integrity.
@@ -285,16 +289,16 @@ function assessLater(scan: Scan, packages: LockedPackage[]) {
285
289
  if (fresh.length > 0) scan.lookups.add(fresh)
286
290
  }
287
291
 
288
- type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
289
292
  type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
290
293
 
291
- // A lockfile's advisories line (repository-risk-audit.ts auditLockfile);
292
- // null where it locks none, or once the scan stops.
293
- async function readAdvisories(scan: Scan, { repo, path, format, packages }: LockfileLine & { packages: LockedPackage[] }, sha: string): Promise<AdvisoriesLine | null> {
294
- const audit = await auditLockfile(scan.source, scan.caches.advisories, scan.signal, `${format}\0${sha}`, packages)
295
- const at = { type: 'advisories', repo, path } as const
296
- return audit === null ? null : Array.isArray(audit) ? { ...at, advisories: audit } : { ...at, ...audit }
297
- }
294
+ // A lockfile's advisories line, of its audit (repository-risk-audit.ts auditLockfile).
295
+ const advisoriesLine = ({ repo, path }: LockfileLine, audit: Audit) => ({ type: 'advisories', repo, path, ...Array.isArray(audit) ? { advisories: audit } : audit } as const)
296
+
297
+ // A repository read, its lockfiles' audits asked meanwhile, AUDITS_AT_ONCE
298
+ // across the scan (auditLockfiles), to tell in turn once access is checked again.
299
+ type Audited = (RepositoryRead & { audits: Promise<Audit | null>[] }) | RepositoryFailure
300
+ const audited = (scan: Scan, read: RepositoryRead | RepositoryFailure | null): Audited | null => (read === null || 'type' in read ? read : { ...read, audits: auditLockfiles(scan.source, scan.caches.advisories,
301
+ scan.signal, read.lockfiles.map(({ sha, line }) => ({ key: `${line.format}\0${sha}`, packages: 'packages' in line ? line.packages : null })), scan.audits) })
298
302
 
299
303
  type DependabotLine = Extract<RepositoryRiskLine, { type: 'dependabot' }>
300
304
 
@@ -319,10 +323,11 @@ async function rechecked(read: DependabotRead): Promise<DependabotRead> {
319
323
 
320
324
  // A repository read (repository-risk-read.ts readRepository) told: its
321
325
  // line, or why it couldn't be read, then its lockfiles', and their
322
- // advisories, its packages assessed meanwhile, then its Dependabot alerts.
326
+ // advisories, asked as it was read, its packages assessed meanwhile, then
327
+ // its Dependabot alerts.
323
328
  // Access to it is checked again
324
329
  // just before, however long ago it was read, as GitHub may have withdrawn it.
325
- async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: RepositoryRead | RepositoryFailure | null) {
330
+ async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: Audited | null) {
326
331
  if (!read || scan.signal.aborted) return
327
332
  if ('type' in read) { scan.lines.add(read); return }
328
333
  try { await read.recheck() } catch (error) {
@@ -337,10 +342,10 @@ async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: Reposi
337
342
  if ('packages' in line) assessLater(scan, line.packages)
338
343
  }
339
344
  const refs = read.actions ? tellActions(scan, repo, read.actions, lockfiles) : []
340
- for (const { sha, line } of lockfiles) {
345
+ for (const [at, { line }] of lockfiles.entries()) {
341
346
  if (scan.signal.aborted) return
342
- const advisories = 'packages' in line ? await readAdvisories(scan, line, sha) : null
343
- if (advisories) scan.lines.add(advisories)
347
+ const audit = await read.audits[at]
348
+ if (audit) scan.lines.add(advisoriesLine(line, audit))
344
349
  }
345
350
  if (scan.signal.aborted) return
346
351
  const dependabot = read.dependabot && await rechecked(read.dependabot)
@@ -366,9 +371,10 @@ function tellActions(scan: Scan, repo: RepositoryRiskRepo, read: WorkflowsRead,
366
371
  async function toldBefore(scan: Scan, repo: RepositoryRiskRepo) {
367
372
  const told = scan.told.get(repo.repoId)
368
373
  if (!told || told.name !== repo.fullName || JSON.stringify(told.paths) !== JSON.stringify(repo.paths ?? null)) return false
369
- const reads: LockedPackage[][] = []
374
+ const reads: LockedPackage[][] = [], unknown = told.lockfiles.filter(key => !scan.caches.lockfiles.get(key))
375
+ const stored = unknown.length > 0 && scan.source.lockfiles ? await scan.source.lockfiles.readMany(unknown) : null
370
376
  for (const key of told.lockfiles) {
371
- const read = scan.caches.lockfiles.get(key) ?? (await scan.source.lockfiles?.read(key))?.value
377
+ const read = scan.caches.lockfiles.get(key) ?? stored?.get(key)?.value
372
378
  if (!read || !('packages' in read)) return false
373
379
  reads.push(read.packages)
374
380
  }
@@ -388,11 +394,11 @@ async function readRepositories(scan: Scan) {
388
394
  const kept = await scan.source.progress?.read()
389
395
  const progress = kept && Date.now() - kept.at < SCAN_READ_KEPT_MS ? kept.value.repos : []
390
396
  for (const told of progress) if (listed.has(told.id)) scan.told.set(told.id, told)
391
- const reads: Promise<RepositoryRead | RepositoryFailure | null>[] = []
397
+ const reads: Promise<Audited | null>[] = []
392
398
  const read = (at: number) => {
393
399
  if (reads[at]) return reads[at]
394
400
  const repo = repos[at]!
395
- const started = reads[at] = toldBefore(scan, repo).then(told => told ? null : readRepository(scan, repo))
401
+ const started = reads[at] = toldBefore(scan, repo).then(async told => (told ? null : audited(scan, await readRepository(scan, repo))))
396
402
  // One read ahead and no longer waited on, as the scan stopped, fails alone.
397
403
  started.catch(() => {})
398
404
  return started
@@ -438,7 +444,8 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
438
444
  const lines = queue<RepositoryRiskLine>(AbortSignal.any([signal, part.signal]), { drain: true })
439
445
  const actions = source.actions ? resolvingActions({ source: source.actions, signal: running, tell: line => { lines.add(line) }, memory: caches.actions,
440
446
  answers: caches.actionAnswers, store: source.actionLines }) : null
441
- const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions }
447
+ const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions,
448
+ audits: inTurns(AUDITS_AT_ONCE) }
442
449
  const failed: unknown[] = []
443
450
  void (async () => {
444
451
  try {