@preventive/triage 1.0.0-alpha.43 → 1.0.0-alpha.44

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 (57) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/managed/routes.js +3 -3
  6. package/common/server-info.ts +8 -0
  7. package/common/workspace-app.js +14 -1
  8. package/out/chunk-2ABLKCOY.js +46 -0
  9. package/out/chunk-5LSXOLUS.js +1 -0
  10. package/out/chunk-5RCM2J63.js +1 -0
  11. package/out/chunk-7B6HB4D7.js +1 -0
  12. package/out/{chunk-4F6JSF57.js → chunk-AJ4GTATZ.js} +1 -1
  13. package/out/chunk-CH4UN6WK.js +225 -0
  14. package/out/chunk-EDKLBQQN.js +17 -0
  15. package/out/{chunk-UXNUAIIC.js → chunk-F47DXCEV.js} +1 -1
  16. package/out/chunk-FK3DZEKL.js +1 -0
  17. package/out/chunk-G6AJWENO.js +1 -0
  18. package/out/chunk-GZWM7UQM.js +2 -0
  19. package/out/chunk-JCIIBLKR.js +1 -0
  20. package/out/chunk-LPCTJYNA.js +7 -0
  21. package/out/{chunk-LAAB5SZT.js → chunk-O4RJY5TP.js} +1 -1
  22. package/out/chunk-OD4LPN3H.js +1 -0
  23. package/out/{chunk-A7M4GV7M.js → chunk-QWDWTBM7.js} +1 -1
  24. package/out/chunk-QYWKC557.js +1 -0
  25. package/out/chunk-R75XOKUP.js +1 -0
  26. package/out/{chunk-RSHMVYNS.js → chunk-SWTLADW5.js} +1 -1
  27. package/out/chunk-T7OKH4VW.js +1 -0
  28. package/out/{chunk-ZMQQCGF6.js → chunk-UTR2LVME.js} +1 -1
  29. package/out/chunk-ZESLQCTF.js +6 -0
  30. package/out/client-managed-import.js +1 -1
  31. package/out/client-managed.js +20 -20
  32. package/out/client-sync.js +1 -1
  33. package/out/graph.js +1 -1
  34. package/out/index.html +14 -10
  35. package/out/terminal.js +1 -1
  36. package/out/view.css +1 -1
  37. package/out/view.js +48 -48
  38. package/package.json +7 -3
  39. package/risk/npm/assess.js +12 -7
  40. package/risk/npm/dependencies.js +11 -0
  41. package/server-managed/comparison.ts +41 -0
  42. package/server-managed/http.ts +99 -31
  43. package/server-managed/locked-packages.ts +11 -0
  44. package/server-managed/pretty-print.ts +14 -9
  45. package/server-managed/public-workspace.ts +26 -8
  46. package/server-managed/repository-risk.ts +11 -9
  47. package/server-managed/team-app.ts +38 -14
  48. package/out/chunk-7RU5EDRO.js +0 -226
  49. package/out/chunk-CLPKNNC7.js +0 -51
  50. package/out/chunk-NWZ4PVPN.js +0 -1
  51. package/out/chunk-OSIMGFUF.js +0 -1
  52. package/out/chunk-RFZQQXSG.js +0 -17
  53. package/out/chunk-VQJCXGIY.js +0 -7
  54. package/out/chunk-WZDTZE64.js +0 -1
  55. package/out/chunk-XOCAPDCM.js +0 -1
  56. package/out/chunk-YVZH5SHP.js +0 -1
  57. package/out/chunk-ZBBOBQ43.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.43",
3
+ "version": "1.0.0-alpha.44",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": {
@@ -108,6 +108,7 @@
108
108
  "server-managed/cache-storage.ts",
109
109
  "server-managed/cli.js",
110
110
  "server-managed/combined.ts",
111
+ "server-managed/comparison.ts",
111
112
  "server-managed/comments.ts",
112
113
  "server-managed/config.ts",
113
114
  "server-managed/content-access.ts",
@@ -206,6 +207,9 @@
206
207
  "common/bundle-commit.d.ts",
207
208
  "common/bundle-commit.js",
208
209
  "common/bundle-compare-diff.js",
210
+ "common/bundle-compare-versions.js",
211
+ "common/bundle-comparison.d.ts",
212
+ "common/bundle-comparison.js",
209
213
  "common/bundle-compare-diff.d.ts",
210
214
  "common/bundle-compare-inputs.d.ts",
211
215
  "common/bundle-compare-inputs.js",
@@ -352,9 +356,9 @@
352
356
  "dependencies": {
353
357
  "@exodus/stasis": "1.0.0-beta.9",
354
358
  "@exodus/stasis-core": "1.0.0-beta.9",
355
- "@preventive/compare": "^1.2.8",
359
+ "@preventive/compare": "^1.2.10",
356
360
  "@preventive/diff": "^1.0.2",
357
- "@preventive/lockfile": "1.1.0",
361
+ "@preventive/lockfile": "1.1.1",
358
362
  "@preventive/publishers": "^1.1.0",
359
363
  "@preventive/report": "1.0.0-alpha.9",
360
364
  "@preventive/sourcemap": "1.0.0-alpha.1",
@@ -6,7 +6,7 @@ import { npmBaseFilesTell, npmCompareFindings } from './compare.js'
6
6
  import { npmCombinedFindings, npmHeldDependencyFindings } from './combined.js'
7
7
  import { npmHeldFindings, npmVersionFindings } from './held.js'
8
8
  import { npmVersionFileRisk } from './files.js'
9
- import { npmDependencyChanges, npmFlagDependencyChanges } from './dependencies.js'
9
+ import { npmDependencyChanges, npmFlagDependencyChanges, npmUnusedDependencyCounted } from './dependencies.js'
10
10
  import { npmDownloadMonths, npmDownloadTier, npmSettledDownloads, npmTierLevel } from './downloads.js'
11
11
  import { NpmRiskError, npmDownloads, npmPackageFacts, npmPackageReleases, npmPublicFacts, npmVersionAdvisories, npmVersionFiles } from './registry.js'
12
12
  import { npmMalwareFinding } from './malware.js'
@@ -127,7 +127,8 @@ const MAX_TOLD_DEPENDENCIES = 100
127
127
  // from }`), `uncounted` where npm counts none of them, `created` and `gap`
128
128
  // where it is new since the version compared with (`published`, when that
129
129
  // and the open version were), and for one nothing imports (flagged so, or
130
- // among `unusedKeys`), the version its spec installs (`installs`,
130
+ // among `unusedKeys`) that risk counts (dependencies.js
131
+ // npmUnusedDependencyCounted), the version its spec installs (`installs`,
131
132
  // registry.js npmPublicFacts); `missing` where
132
133
  // its package was looked up and npm has none of it for anyone, as once
133
134
  // unpublished or taken down; four asked at a time, whether each is the
@@ -139,7 +140,7 @@ async function addedDependencies(added, published, context, owned, unusedKeys =
139
140
  let next = 0, taken = 0
140
141
  const tell = async row => {
141
142
  const downloads = await npmDownloads(row.opens, context)
142
- const unused = row.flags.some(flag => flag.check === 'unimported') || unusedKeys.has(keyOf(row))
143
+ const unused = (row.flags.some(flag => flag.check === 'unimported') || unusedKeys.has(keyOf(row))) && npmUnusedDependencyCounted(row)
143
144
  let facts = npmAddedDependency({ downloads, published, unused }, context.now), missing = false
144
145
  if (facts.wanted) {
145
146
  const listed = await npmPublicFacts(row.opens, context, unused ? row.range : null)
@@ -225,8 +226,9 @@ export async function assessNpm(name, version, { oldVersion = null, fromEmpty =
225
226
  const keyOf = row => `${row.kind ?? ''}\0${row.name}`
226
227
 
227
228
  // The keys of the dependencies a version holds that nothing of it imports
228
- // (`kept`, npmLastingRisk's: its `held-unused-deps`).
229
- const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'held-unused-deps')?.dependencies ?? []).map(keyOf))
229
+ // (`kept`, npmLastingRisk's: its `held-unused-deps`), those risk counts
230
+ // (dependencies.js npmUnusedDependencyCounted).
231
+ const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'held-unused-deps')?.dependencies ?? []).filter(npmUnusedDependencyCounted).map(keyOf))
230
232
 
231
233
  // The dependencies nothing of a version imports (`held-unused-deps`), each
232
234
  // graded by what risk tells of its package fresh (`told`, as
@@ -236,13 +238,16 @@ const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'hel
236
238
  // version compared with where there was one (`compared`) and it named it as
237
239
  // the same kind of dependency by the same spec: not one marked new, nor
238
240
  // `added` (its kind and name new, as one moved from another kind is), nor
239
- // `changed` (each said with the spec it had, `was`). The finding as its most severe.
241
+ // `changed` (each said with the spec it had, `was`); one risk doesn't count
242
+ // (dependencies.js npmUnusedDependencyCounted) a notice, told all the same.
243
+ // The finding as its most severe.
240
244
  function unusedGraded(finding, told, compared, { added, changed }) {
241
245
  const facts = new Map(told.map(row => [keyOf(row), row])), was = new Map(changed.map(row => [keyOf(row), row.from]))
242
246
  const anew = new Set(added.map(keyOf))
243
247
  const dependencies = finding.dependencies.map(row => {
244
248
  const of = facts.get(keyOf(row))
245
- return { ...row, ...was.has(keyOf(row)) ? { was: was.get(keyOf(row)) } : {}, severity: npmUnusedDependencySeverity(of, compared && !row.added && !anew.has(keyOf(row)) && !was.has(keyOf(row))),
249
+ return { ...row, ...was.has(keyOf(row)) ? { was: was.get(keyOf(row)) } : {}, severity: npmUnusedDependencyCounted(row)
250
+ ? npmUnusedDependencySeverity(of, compared && !row.added && !anew.has(keyOf(row)) && !was.has(keyOf(row))) : 'notice',
246
251
  ...of?.level ? { tier: of.tier, level: of.level } : {}, ...of?.created ? { created: of.created, gap: of.gap } : {}, ...of?.installs ? { installs: of.installs } : {},
247
252
  ...of?.missing ? { missing: true } : {} }
248
253
  })
@@ -125,6 +125,17 @@ export function npmDependencyUnimported(details, { name, kind, range, to }) {
125
125
  && !commands.some(command => commandNames(command, name))
126
126
  }
127
127
 
128
+ // Whether a dependency nothing of a version imports (`{ name, range }`,
129
+ // npmDependencyUnimported) counts toward its risk (assess.js): not lodash,
130
+ // which packages keep naming long after their code stopped importing it, nor
131
+ // Babel's own presets (`@babel/preset-*`), which a config names by a string
132
+ // (an ESLint config's parser options); each where npm installs that package
133
+ // from its registry (installsAs), not another under its name.
134
+ export function npmUnusedDependencyCounted({ name, range }) {
135
+ const installs = installsAs(name, range)
136
+ return !(name === 'lodash' && installs === 'lodash') && !(name.startsWith('@babel/preset-') && installs?.startsWith('@babel/preset-'))
137
+ }
138
+
128
139
  // Where a source takes a dependency from, in a sentence.
129
140
  const SOURCE_NAMES = new Map([['URL', 'a URL'], ['git', 'a git repository']])
130
141
 
@@ -0,0 +1,41 @@
1
+ // Compare's comparison of two managed bundles or two versions of an npm
2
+ // package (GET /api/bundles/:id/comparison?other=, GET /api/npm/comparison):
3
+ // their files paired, at one path or renamed, with the sizes, resolutions,
4
+ // dependency versions and line counts its Overview and Code read beside them,
5
+ // as common/bundle-comparison.js prepareComparison works them out. The viewer
6
+ // of a managed bundle or a package has every comparison worked out here and
7
+ // loads none of the pairing itself. Answered as Brotli-encoded JSON, its
8
+ // `files.basePaths` (a Map) as `[path, as laid out]` pairs where the two
9
+ // sides' roots differ and none else, as the viewer reads none then. Access is
10
+ // the caller's to check.
11
+ import { Buffer } from 'node:buffer'
12
+ import { Readable } from 'node:stream'
13
+ import { prepareComparison } from '../common/bundle-comparison.js'
14
+ import type { BundleDetails } from '../common/bundle-metadata.js'
15
+ import type { OpenedBlob } from './blob-store.ts'
16
+ import { encodeBrotli } from './brotli.ts'
17
+ import { type NpmPackageFile, npmFileRows } from './npm-packages.ts'
18
+
19
+ // A side with no files, as Review compares a package's first version with:
20
+ // one for each comparison, as working one out keeps its line counts on it.
21
+ export const emptySide = (): BundleDetails => ({ integrity: '(empty)', kind: 'sourcemap', size: 0, json: { version: 3, sources: [], sourcesContent: [] } })
22
+
23
+ // A version's files as the viewer reads them for a comparison (ui/view/npm-package.js
24
+ // npmPackageDetails): its texts, and those that are not text by their size and digest.
25
+ export function npmComparedSide(integrity: string, files: NpmPackageFile[]): BundleDetails & { npmBinaries: Map<string, { size: number; digest: string }> } {
26
+ const rows = npmFileRows(files)
27
+ return {
28
+ integrity, kind: 'sourcemap', size: 0,
29
+ json: { version: 3, sources: rows.map(([path]) => path), sourcesContent: rows.map(([, , text]) => text) },
30
+ npmBinaries: new Map(rows.flatMap(([path, size, text, digest]) => (text === null ? [[path, { size, digest: digest! }] as const] : []))),
31
+ }
32
+ }
33
+
34
+ // The comparison of `base` with `other`, within a reason `scope` where given, as sent.
35
+ export async function comparisonBody(base: BundleDetails, other: BundleDetails, scope = ''): Promise<OpenedBlob> {
36
+ const comparison = prepareComparison(base, other, scope)
37
+ const { files } = comparison
38
+ const json = JSON.stringify({ ...comparison, files: { ...files, basePaths: files.root ? [...files.basePaths] : [] } })
39
+ const body = await encodeBrotli(Buffer.from(json))
40
+ return { size: body.byteLength, stream: Readable.from([body]) }
41
+ }
@@ -3,7 +3,8 @@
3
3
  // server doesn't speak the e2e sync protocol yet). Every handler is async; the
4
4
  // boot's `track()` keeps in-flight requests drainable on shutdown.
5
5
  //
6
- // GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public)
6
+ // GET /api/config → { mode:'managed', managed:{ loginPath, cookieName }, session } (public;
7
+ // `session` is /api/auth/session's answer, null signed out, absent when it could not be read)
7
8
  // GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
8
9
  // GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
9
10
  // GET /api/auth/session → { user, csrfToken } | 401
@@ -120,7 +121,7 @@ import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedR
120
121
  import type { ReportSourcesCache } from './report-sources.ts'
121
122
  import { normalizeTeamPath } from './repo-path.ts'
122
123
  import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
123
- import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
124
+ import { CONFIG_PATH, type ManagedSessionInfo, type ServerInfo } from '../common/server-info.ts'
124
125
  import { bundleCommitHash } from '../common/bundle-commit.js'
125
126
  import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
126
127
  import { upstreamGithub } from './github-errors.ts'
@@ -146,7 +147,7 @@ import { attachmentDisposition, sendJson, sendLines, writeResponse } from './htt
146
147
  import { triageWireEntry } from './triage-response.ts'
147
148
  import { handlePublicWorkspace } from './public-workspace.ts'
148
149
  import { serveUserTeamFeed } from './team-feed.ts'
149
- import { teamAppKey, teamAppStates } from './team-app.ts'
150
+ import { teamAppKey, teamAppStates, withWorkspaceSummary } from './team-app.ts'
150
151
  import { hashToken, randomToken, safeEqual } from './crypto.ts'
151
152
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
152
153
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
@@ -163,6 +164,7 @@ import { NpmRiskError } from '../risk/index.js'
163
164
  import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
164
165
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
165
166
  import { typeOnlyFiles } from './type-only.ts'
167
+ import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
166
168
  import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
167
169
 
168
170
  const SESSION_PATH = '/api/auth/session'
@@ -216,6 +218,7 @@ const NPM_PROVENANCE_PATH = '/api/npm/provenance'
216
218
  const NPM_PRETTY_PATH = '/api/npm/pretty'
217
219
  const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
218
220
  const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
221
+ const NPM_COMPARISON_PATH = '/api/npm/comparison'
219
222
  const NPM_RISK_PATH = '/api/npm/risk'
220
223
  // What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
221
224
  // having no such package or version, or a tarball unpacking past risk's
@@ -229,7 +232,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
229
232
  sendJson(res, status, { error: err.code })
230
233
  }
231
234
  const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
232
- NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
235
+ NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH])
233
236
  // How long an npm response may go unread before its connection is dropped.
234
237
  const NPM_RESPONSE_IDLE_MS = 60_000
235
238
  const MAX_TEAM_NAME = 100
@@ -581,6 +584,24 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
581
584
  return s
582
585
  }
583
586
 
587
+ // Who am I? While an admin views as another user, that user, plus the admin
588
+ // as `viewer`. A view that has ended (with the admin's session or role, or the
589
+ // viewed account) is cleared, returning to the own session. Null signed out.
590
+ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
591
+ const now = Date.now()
592
+ let s = await readSession(config, db, cookie, now)
593
+ const ended = s == null && viewToken(config, cookie) != null
594
+ if (ended) s = await readSession(config, db, cookie, now, { own: true })
595
+ return {
596
+ body: s && {
597
+ user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
598
+ csrfToken: s.session.csrfToken,
599
+ ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
600
+ },
601
+ headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
602
+ }
603
+ }
604
+
584
605
  // GET /api/admin/scan/models — the server's canonical scan model ids and effort
585
606
  // levels. Names are intentionally absent; the client derives them from ids.
586
607
  async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
@@ -1515,18 +1536,25 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
1515
1536
  await sendEncoded(req, res, cached, contentType, headers)
1516
1537
  }
1517
1538
 
1539
+ // Two bundles a reader asks about together, where they may read both, else
1540
+ // null, told as 401 or 404: asked before what is worked out of them and
1541
+ // again before it is sent.
1542
+ function bothBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string) {
1543
+ return async () => {
1544
+ const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
1545
+ if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
1546
+ if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
1547
+ return [base.bundle, other.bundle] as const
1548
+ }
1549
+ }
1550
+
1518
1551
  // GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
1519
1552
  // one of the `other` bundle's, both pretty-printed, renamed names hidden
1520
1553
  // (rename-diff.ts), for a reader of both, checked again once it is worked out.
1521
1554
  async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
1522
1555
  const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
1523
1556
  if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
1524
- const readable = async () => {
1525
- const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
1526
- if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
1527
- if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
1528
- return [base.bundle, other.bundle] as const
1529
- }
1557
+ const readable = bothBundles(res, deps, session, id, otherId)
1530
1558
  const records = await readable()
1531
1559
  if (!records) return
1532
1560
  const { prettyCache } = deps
@@ -1542,6 +1570,25 @@ async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse,
1542
1570
  else diff.stream.destroy()
1543
1571
  }
1544
1572
 
1573
+ // GET /api/bundles/:id/comparison?other=: its files paired with the `other`
1574
+ // bundle's as Compare pairs them, with what its Overview reads beside them
1575
+ // (comparison.ts), within a reason `scope` where given; for a reader of
1576
+ // both, checked again once it is worked out.
1577
+ async function handleBundleComparison(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
1578
+ const otherId = params.get('other') ?? ''
1579
+ if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
1580
+ const readable = bothBundles(res, deps, session, id, otherId)
1581
+ const records = await readable()
1582
+ if (!records) return
1583
+ const { prettyCache } = deps
1584
+ if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1585
+ let body
1586
+ try { body = await comparisonBody(await prettyCache.bundleDetails(records[0]), await prettyCache.bundleDetails(records[1]), params.get('scope') ?? '') }
1587
+ catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
1588
+ if (await readable()) await sendEncoded(req, res, body, 'application/json')
1589
+ else body.stream.destroy()
1590
+ }
1591
+
1545
1592
  // GET /api/bundles/:id/pretty?other=: the files that changed between it and
1546
1593
  // the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
1547
1594
  // line each as it is found kept or formatted; GET
@@ -1898,7 +1945,7 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
1898
1945
  // The revision sent is the reloaded catalog's, so classify what changed now.
1899
1946
  const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
1900
1947
  sendJson(res, 200, {
1901
- teams: teams.map(team => ({ ...team, app: apps.get(teamAppKey(team)) ?? null,
1948
+ teams: teams.map(team => ({ ...withWorkspaceSummary(team, apps.get(teamAppKey(team))),
1902
1949
  bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
1903
1950
  commitInfo: commits.commitInfo(bundle) })) })),
1904
1951
  revision,
@@ -2854,7 +2901,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2854
2901
  } finally { res.off('close', onClose) }
2855
2902
  }
2856
2903
 
2857
- // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
2904
+ // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
2858
2905
  // (canReadPrivateNpm) can be answered from the server's npm token; the rest get
2859
2906
  // what the registry answers anonymously, every time (see npm-packages.ts).
2860
2907
  // Access is checked again after the registry's answer.
@@ -2864,14 +2911,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2864
2911
  // All but the package's own (its versions, figures and advisories) are a version's.
2865
2912
  const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
2866
2913
  const otherSpec = params.get('otherVersion') ?? ''
2867
- if (!isNpmPackageName(name) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
2914
+ // The package a comparison's other side is a version of, another where Review compares a dependency replaced.
2915
+ const otherName = path === NPM_COMPARISON_PATH ? params.get('otherName') ?? name : name
2916
+ if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
2868
2917
  || (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
2869
- || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
2918
+ || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
2919
+ || (path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
2870
2920
  sendJson(res, 400, { error: 'bad-package' }); return
2871
2921
  }
2872
2922
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
2873
2923
  if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
2874
- const reader = await npmReader(deps, cookie, [name])
2924
+ const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
2875
2925
  if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2876
2926
  const controller = new AbortController()
2877
2927
  const onClose = () => { if (!res.writableEnded) controller.abort() }
@@ -2970,6 +3020,28 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2970
3020
  return
2971
3021
  }
2972
3022
  if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
3023
+ // Its files paired with those of `otherVersion` (of `otherName`, else the
3024
+ // same package), as Compare and Review pair them, with what their
3025
+ // Overview reads beside them (comparison.ts): the version shown against
3026
+ // the one compared with, or `(empty)`, none, as Review reads a first
3027
+ // version. Read of both, each package's access checked again once worked out.
3028
+ if (path === NPM_COMPARISON_PATH) {
3029
+ const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
3030
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
3031
+ const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
3032
+ let body
3033
+ try {
3034
+ const [files, oldFiles] = await Promise.all(loads.map(load => load.result))
3035
+ body = await comparisonBody(old ? npmComparedSide(old.dist.integrity, oldFiles!) : emptySide(), npmComparedSide(doc.dist.integrity, files!))
3036
+ } finally { for (const load of loads) load.release() }
3037
+ const current = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
3038
+ if (current == null) { body.stream.destroy(); sendJson(res, 401, { error: 'unauthenticated' }); return }
3039
+ if ((doc.private && !canReadPrivateNpm(current, name)) || (old?.private && !canReadPrivateNpm(current, otherName))) {
3040
+ body.stream.destroy(); sendJson(res, 404, { error: 'package-not-found' }); return
3041
+ }
3042
+ if (!res.destroyed) await sendEncoded(req, res, body, 'application/json')
3043
+ return
3044
+ }
2973
3045
  // Every one of its files that needs it pretty-printed (pretty-print.ts
2974
3046
  // npmAll), or, against another version, the files that changed between
2975
3047
  // them, both sides' (compared), a line each as it is formatted, or found
@@ -3110,13 +3182,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3110
3182
  }
3111
3183
 
3112
3184
  // Public mode probe — lets a client detect the managed protocol up front.
3185
+ // It also sends the session, sparing the client's GET /api/auth/session;
3186
+ // a failed read leaves that request to the client, never failing the probe.
3113
3187
  if (path === CONFIG_PATH) {
3114
3188
  if (method !== 'GET') { send405(res, 'GET'); return }
3115
3189
  const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
3190
+ const current = await currentSession(config, db, cookie).catch((err) => { console.warn('managed: config session read failed:', err); return null })
3116
3191
  sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
3117
3192
  managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
3118
3193
  uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
3119
- } : {}) } })
3194
+ } : {}) }, ...(current ? { session: current.body } : {}) }, current?.headers)
3120
3195
  return
3121
3196
  }
3122
3197
  if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
@@ -3159,22 +3234,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3159
3234
  res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
3160
3235
  res.end(); return
3161
3236
  }
3162
- // Who am I? While an admin views as another user, that user, plus the
3163
- // admin as `viewer`. A view that has ended (with the admin's session or
3164
- // role, or the viewed account) is cleared, returning to the own session.
3165
3237
  if (path === SESSION_PATH) {
3166
3238
  if (method !== 'GET') { send405(res, 'GET'); return }
3167
- const now = Date.now()
3168
- let s = await readSession(config, db, cookie, now)
3169
- const ended = s == null && viewing
3170
- if (ended) s = await readSession(config, db, cookie, now, { own: true })
3171
- const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
3172
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
3173
- sendJson(res, 200, {
3174
- user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
3175
- csrfToken: s.session.csrfToken,
3176
- ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
3177
- }, headers)
3239
+ const { body, headers } = await currentSession(config, db, cookie)
3240
+ if (body == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
3241
+ sendJson(res, 200, body, headers)
3178
3242
  return
3179
3243
  }
3180
3244
  if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
@@ -3302,9 +3366,13 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3302
3366
  await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
3303
3367
  return
3304
3368
  }
3305
- const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
3369
+ const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only|comparison)$/iu.exec(path)
3306
3370
  if (bundleRead) {
3307
3371
  const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
3372
+ if (part === 'comparison') {
3373
+ if (method !== 'GET') { send405(res, 'GET'); return }
3374
+ await handleBundleComparison(req, res, deps, session, id, url.searchParams); return
3375
+ }
3308
3376
  if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
3309
3377
  if (method !== 'GET') { send405(res, 'GET'); return }
3310
3378
  if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
@@ -95,3 +95,14 @@ export function lockedPackages(format: LockfileFormat, text: string): LockedPack
95
95
  }
96
96
  return [...found.values()]
97
97
  }
98
+
99
+ // Whether a lockfile at `path` is a test's fixture or example: under a
100
+ // directory of tests (`test`, `tests`, `__tests__` and such), in one of
101
+ // fixtures or examples (`fixture`, `fixtures`, `example`, `examples`) inside
102
+ // it, as a test may keep one its readers refuse on purpose.
103
+ const FIXTURES_DIR = /^(?:fixture|example)s?$/u, TESTS_DIR = /^_*tests?_*$/u
104
+ export function isTestFixture(path: string) {
105
+ const dirs = path.split('/').slice(0, -1)
106
+ const tests = dirs.findIndex(dir => TESTS_DIR.test(dir))
107
+ return tests !== -1 && dirs.slice(tests + 1).some(dir => FIXTURES_DIR.test(dir))
108
+ }
@@ -250,18 +250,21 @@ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) =>
250
250
  }
251
251
  }
252
252
 
253
+ // A bundle's details, from its bytes in `store`: bundles are read one at a
254
+ // time, as reading one decodes all of it.
255
+ function detailsReader(store: BundleStore) {
256
+ const enqueue = serial()
257
+ return (record: ManagedBundle) => enqueue(async () => {
258
+ const details = await readBundleDetails(record, store)
259
+ if (!details) throw new PrettyError(422, 'bundle-unavailable')
260
+ return details
261
+ })
262
+ }
263
+
253
264
  export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
254
265
  // Copies being made; readers asking for one meanwhile share it.
255
266
  const shared = sharing<Buffer>()
256
- // Bundles are read one at a time: reading one decodes all of it.
257
- const enqueue = serial()
258
- function bundleDetails(record: ManagedBundle) {
259
- return enqueue(async () => {
260
- const details = await readBundleDetails(record, store)
261
- if (!details) throw new PrettyError(422, 'bundle-unavailable')
262
- return details
263
- })
264
- }
267
+ const bundleDetails = detailsReader(store)
265
268
  // A bundle's file pretty-printed from the text `read` reads, and kept in
266
269
  // its cache.
267
270
  function bundleCopy(record: ManagedBundle, request: PrettyRequest, read: () => Promise<string>): Promise<Buffer> {
@@ -341,6 +344,8 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
341
344
  made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
342
345
  return [await sideOf(base, 'base'), await sideOf(other, 'other')]
343
346
  },
347
+ // A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
348
+ bundleDetails,
344
349
  async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
345
350
  const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
346
351
  kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
@@ -17,7 +17,8 @@ import { serveTeamFeed } from './team-feed.ts'
17
17
  import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
18
18
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
19
19
  import { typeOnlyFiles } from './type-only.ts'
20
- import { sharedTeamApp } from './team-app.ts'
20
+ import { comparisonBody } from './comparison.ts'
21
+ import { sharedTeamApp, withWorkspaceSummary } from './team-app.ts'
21
22
 
22
23
  function json(res: ServerResponse, status: number, body: unknown): void {
23
24
  sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
@@ -35,7 +36,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
35
36
  const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
36
37
  const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
37
38
  const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
38
- const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only)$/u.exec(url.pathname)
39
+ const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only|comparison)$/u.exec(url.pathname)
39
40
  if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
40
41
  const tokenHash = hashToken(token)
41
42
  const snapshot = await deps.db.getWorkspaceShare(tokenHash)
@@ -46,8 +47,8 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
46
47
  }
47
48
  const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
48
49
  const sendTeam = async () => {
49
- const [summaries, app] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
50
- await send({ user: snapshot.user, team: { ...snapshot.team, app,
50
+ const [summaries, workspace] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
51
+ await send({ user: snapshot.user, team: { ...withWorkspaceSummary(snapshot.team, workspace),
51
52
  bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
52
53
  } })
53
54
  await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
@@ -101,7 +102,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
101
102
  const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
102
103
  if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
103
104
  if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
104
- if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
105
+ if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || bundleRoute![2] === 'comparison' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
105
106
  if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
106
107
  await serveCompared(res, deps, bundle, snapshot.bundles.find(item => item.id === url.searchParams.get('other')), bundleRoute![2], url, recheck, stream); return
107
108
  }
@@ -109,11 +110,12 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
109
110
  }
110
111
 
111
112
  // What a bundle is compared with `other`, another of the link's bundles, by:
112
- // one of their JavaScript files diffed with renamed names hidden, or the
113
- // files that changed between them, or the pairs of those, or those that
114
- // changed in types alone, a line each.
113
+ // their files paired, as one comparison; one of their JavaScript files diffed
114
+ // with renamed names hidden; or the files that changed between them, or the
115
+ // pairs of those, or those that changed in types alone, a line each.
115
116
  function serveCompared(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, part: string, url: URL,
116
117
  recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
118
+ if (part === 'comparison') return serveComparison(res, deps, bundle, other, url, stream)
117
119
  if (part === 'rename-diff' && url.searchParams.has('path')) return serveRenameDiff(res, deps, bundle, other, url, stream)
118
120
  return serveChanges(res, deps, bundle, other, recheck, part === 'pretty' ? compared : part === 'type-only' ? typeOnlyFiles : renamedPairs,
119
121
  part === 'pretty' ? '' : url.searchParams.get('scope') ?? '')
@@ -139,6 +141,22 @@ async function serveRenameDiff(res: ServerResponse, deps: ManagedHttpDeps, bundl
139
141
  await stream(diff, 'br')
140
142
  }
141
143
 
144
+ // A bundle's files paired with `other`'s, another of the link's bundles, as
145
+ // Compare pairs them (comparison.ts), within a reason `scope` where given.
146
+ async function serveComparison(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, url: URL,
147
+ stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
148
+ if (!other) { json(res, 404, { error: 'no-bundle' }); return }
149
+ const { prettyCache } = deps
150
+ if (!prettyCache) { json(res, 503, { error: 'unavailable' }); return }
151
+ let body
152
+ try { body = await comparisonBody(await prettyCache.bundleDetails(bundle), await prettyCache.bundleDetails(other), url.searchParams.get('scope') ?? '') }
153
+ catch (err) {
154
+ if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
155
+ throw err
156
+ }
157
+ await stream(body, 'br')
158
+ }
159
+
142
160
  // The files that changed between a bundle and `other`, another of the link's
143
161
  // bundles, both sides' pretty-printed (pretty-print.ts compared), or the pairs
144
162
  // of them its viewer diffs with renamed names hidden (rename-diff.ts
@@ -28,7 +28,7 @@ import type { NpmVersionDocument } from './npm-packages.ts'
28
28
  import { type Lru, lru } from './lru.ts'
29
29
  import type { KeptStore } from './npm-kept.ts'
30
30
  import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
31
- import { type LockedPackage, lockedPackages } from './locked-packages.ts'
31
+ import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
32
32
  import { type Queue, queue } from './queue.ts'
33
33
  import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
34
34
  import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
@@ -334,33 +334,35 @@ async function readAdvisories(scan: Scan, { repo, path, format, packages }: Lock
334
334
  // A repository's line, its lockfiles or why they couldn't be listed, and
335
335
  // then its lockfiles', and their advisories: told once they are all read
336
336
  // and access to the repository is checked again, as GitHub may have
337
- // withdrawn it meanwhile.
337
+ // withdrawn it meanwhile. A test's fixture or example its reader refuses
338
+ // (isTestFixture) isn't told at all, as a test may keep one so on purpose.
338
339
  async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
339
340
  const listed = await listLockfiles(scan, repo)
340
341
  if (!listed) return
341
- const lockfiles: LockfileLine[] = []
342
+ const lockfiles: { sha: string; line: LockfileLine }[] = []
342
343
  for (const lockfile of listed.lockfiles) {
343
344
  if (scan.signal.aborted) return
344
- lockfiles.push(await readLockfile(scan, repo.repoId, listed.opened, lockfile))
345
+ const line = await readLockfile(scan, repo.repoId, listed.opened, lockfile)
346
+ if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
345
347
  }
346
348
  try { await listed.opened.recheck() } catch (error) {
347
349
  if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, 'checking access to it again') })
348
350
  return
349
351
  }
350
352
  if (scan.signal.aborted) return
351
- scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: listed.lockfiles.map(({ path, format }) => ({ path, format })) })
352
- for (const line of lockfiles) {
353
+ scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: lockfiles.map(({ line: { path, format } }) => ({ path, format })) })
354
+ for (const { line } of lockfiles) {
353
355
  scan.lines.add(line)
354
356
  if ('packages' in line) assessLater(scan, line.packages)
355
357
  }
356
- for (const [at, line] of lockfiles.entries()) {
358
+ for (const { sha, line } of lockfiles) {
357
359
  if (scan.signal.aborted) return
358
- const advisories = 'packages' in line ? await readAdvisories(scan, line, listed.lockfiles[at]!.sha) : null
360
+ const advisories = 'packages' in line ? await readAdvisories(scan, line, sha) : null
359
361
  if (advisories) scan.lines.add(advisories)
360
362
  }
361
363
  if (scan.signal.aborted) return
362
364
  scan.told.set(repo.repoId, { id: repo.repoId, name: repo.fullName, paths: repo.paths ?? null,
363
- lockfiles: lockfiles.flatMap((line, at) => 'packages' in line ? [`${line.format}\0${listed.lockfiles[at]!.sha}`] : []) })
365
+ lockfiles: lockfiles.flatMap(({ sha, line }) => 'packages' in line ? [`${line.format}\0${sha}`] : []) })
364
366
  }
365
367
 
366
368
  // Whether an earlier part told `repo` whole, by its name and a team's