@preventive/triage 1.0.0-alpha.50 → 1.0.0-alpha.52

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 (61) hide show
  1. package/common/managed/finding-detail.d.ts +2 -1
  2. package/common/managed/finding-detail.js +42 -11
  3. package/common/managed/finding-values.d.ts +2 -0
  4. package/common/managed/finding-values.js +44 -0
  5. package/common/managed/lite-name.d.ts +2 -0
  6. package/common/managed/lite-name.js +20 -0
  7. package/common/managed/npm-packages.d.ts +2 -0
  8. package/common/managed/npm-packages.js +18 -1
  9. package/common/managed/routes.js +19 -7
  10. package/common/managed/team-app.d.ts +1 -0
  11. package/common/managed/team-app.js +17 -1
  12. package/out/activity.js +4 -4
  13. package/out/{chunk-75YLHBCM.js → chunk-2K6UN3PC.js} +1 -1
  14. package/out/{chunk-HTBYHSPG.js → chunk-CHRBUGVN.js} +1 -1
  15. package/out/{chunk-4XMP7YVH.js → chunk-FMXWXDR4.js} +1 -1
  16. package/out/chunk-HT2SSA3V.js +1 -0
  17. package/out/{chunk-2WDZPXVL.js → chunk-IMM274IW.js} +1 -1
  18. package/out/{chunk-FKBXNUW4.js → chunk-KOGNQDSJ.js} +1 -1
  19. package/out/chunk-LJ7XRXFU.js +52 -0
  20. package/out/{chunk-PBZPYIG7.js → chunk-MYGJBYYJ.js} +1 -1
  21. package/out/chunk-NCN3M5EP.js +3 -0
  22. package/out/chunk-OGAYMEER.js +232 -0
  23. package/out/client-managed-import.js +1 -1
  24. package/out/client-managed.js +26 -26
  25. package/out/compare.js +14 -14
  26. package/out/dialogs.js +31 -31
  27. package/out/graph.js +1 -1
  28. package/out/index.html +7 -7
  29. package/out/scan.js +2 -2
  30. package/out/view.css +1 -1
  31. package/out/view.js +11 -11
  32. package/package.json +8 -1
  33. package/server-managed/agent-merges.ts +1 -1
  34. package/server-managed/app-agents.ts +4 -2
  35. package/server-managed/commit-lockfiles.ts +7 -4
  36. package/server-managed/comparison.ts +13 -2
  37. package/server-managed/db-neon.ts +13 -3
  38. package/server-managed/db.ts +2 -1
  39. package/server-managed/dependency-review-comments.ts +16 -18
  40. package/server-managed/dependency-reviews.ts +45 -27
  41. package/server-managed/github-app.ts +3 -42
  42. package/server-managed/github-requests.ts +127 -0
  43. package/server-managed/http.ts +433 -129
  44. package/server-managed/listed-activity.ts +39 -0
  45. package/server-managed/locked-packages.ts +28 -5
  46. package/server-managed/npm-kept.ts +18 -4
  47. package/server-managed/npm-patch.ts +325 -0
  48. package/server-managed/pull-dependencies.ts +9 -6
  49. package/server-managed/pull-dependency-reviews.ts +19 -13
  50. package/server-managed/repository-agents.ts +162 -69
  51. package/server-managed/repository-dependency-reviews.ts +6 -5
  52. package/server-managed/repository-pulls.ts +114 -58
  53. package/server-managed/repository-pulse.ts +5 -3
  54. package/server-managed/repository-stamp.ts +41 -0
  55. package/server-managed/reviewed-dependencies.ts +4 -5
  56. package/server-managed/team-app.ts +3 -1
  57. package/server-managed/team-reports.ts +29 -10
  58. package/out/chunk-6LNQTKAV.js +0 -52
  59. package/out/chunk-6S2U2UYG.js +0 -232
  60. package/out/chunk-FC65TPUB.js +0 -1
  61. package/out/chunk-UK7RGJT6.js +0 -3
@@ -1,2 +1,3 @@
1
1
  export const FINDING_DETAIL_FIELDS: readonly string[]
2
- export function liteReportData(data: unknown): unknown
2
+ export function liteReportData(data: unknown, options?: { fold?: boolean }): unknown
3
+ export function liteWorkspace<T extends { filename: string; data: unknown }>(reports: readonly T[], links: readonly (readonly string[])[]): { reports: T[]; folded: boolean }
@@ -1,17 +1,24 @@
1
- import { findingTitle } from '@preventive/report'
1
+ import { findingTitle, reportEntries, revalidateKindOf } from '@preventive/report'
2
+ import { managedTeamRevalidationConflicted } from './team-app.js'
3
+ import { liteName } from './lite-name.js'
2
4
 
3
5
  // A finding's detail: what its popup, the views besides the board and an
4
6
  // export read, and the board does not. Its narrative and evidence, the hash
5
- // its Code button finds a bundle by, and the export it names. A team's
7
+ // its Code button finds a bundle by, the export it names, the facts its card
8
+ // and an export tell (write-md-finding.js PLAIN_FIELDS, CODE_FIELDS, but for
9
+ // `priority`, which the board sorts and filters by), and the run meta its
10
+ // card's run line and a report's header read (its `model` and `type`, which
11
+ // the board's filters read, stay). A team's
6
12
  // reports are first sent without it (server-managed/team-reports.ts), the
7
13
  // board drawn from them, and their findings filled in from the whole reports
8
14
  // (ui/view/managed-details.js).
9
- export const FINDING_DETAIL_FIELDS = Object.freeze(['description', 'evidence', 'impact', 'reproduction', 'recommendation',
10
- 'confidenceReason', 'revalidateVerdict', 'revalidateRecommendation', 'fileHash', 'exportName', 'methodName'])
15
+ export const FINDING_DETAIL_FIELDS = Object.freeze([
16
+ 'description', 'evidence', 'impact', 'reproduction', 'recommendation', 'confidenceReason', 'revalidateVerdict', 'revalidateRecommendation',
17
+ 'fileHash', 'exportName', 'methodName',
18
+ 'severityReason', 'commitHash', 'auditedCommit', 'detectedAt', 'committedAt', 'dateCreated', 'status', 'branch', 'pocStatus', 'parent', 'slug', 'reportPath',
19
+ 'category', 'discoveredIn', 'effort', 'exportsMode',
20
+ ])
11
21
  const DETAIL = new Set(FINDING_DETAIL_FIELDS)
12
- // More of a name than a board card's three lines show.
13
- const NAME_CHARS = 200
14
-
15
22
  // A finding without its detail. One without a title of its own is named by its
16
23
  // description's first line (findingTitle), which it keeps as its description,
17
24
  // as much of it as a card shows.
@@ -20,13 +27,37 @@ function liteFinding(finding) {
20
27
  const lite = Object.fromEntries(Object.entries(finding).filter(([key]) => !DETAIL.has(key)))
21
28
  const titled = typeof finding.title === 'string' && finding.title.trim() !== ''
22
29
  const title = titled ? '' : findingTitle(finding)
23
- if (title) lite.description = title.length > NAME_CHARS ? `${title.slice(0, NAME_CHARS - 1)}…` : title
30
+ if (title) lite.description = liteName(title)
24
31
  return lite
25
32
  }
26
33
 
27
- // A report's data, its findings without their detail.
28
- export function liteReportData(data) {
34
+ const isPass = finding => revalidateKindOf(finding) === 'revalidation'
35
+
36
+ // A report's data, its findings without their detail. A workspace opening in
37
+ // App mode shows of an entry holding the revalidation pass's row that row
38
+ // alone (ui/view/group.js foldedTabs): with `fold`, such an entry is sent as
39
+ // the pass's rows, the rows beneath them left for the whole reports
40
+ // (ui/view/managed-details.js).
41
+ export function liteReportData(data, { fold = false } = {}) {
29
42
  const key = Array.isArray(data?.findings) ? 'findings' : Array.isArray(data?.groups) ? 'groups' : null
30
43
  if (!key) return data
31
- return { ...data, [key]: data[key].map(entry => Array.isArray(entry) ? entry.map(liteFinding) : liteFinding(entry)) }
44
+ const entries = data[key].map(entry => {
45
+ if (!Array.isArray(entry)) return liteFinding(entry)
46
+ const shown = fold && entry.length > 1 && entry.some(isPass) ? entry.filter(isPass) : entry
47
+ return shown.map(liteFinding)
48
+ })
49
+ return { ...data, [key]: entries }
50
+ }
51
+
52
+ const rows = data => (reportEntries(data) ?? []).flat().length
53
+
54
+ // A team's reports as its board is first sent them (server-managed/team-reports.ts),
55
+ // each `liteReportData`, folded where nothing in the workspace contradicts the
56
+ // revalidation pass, which opens a view of it in App mode
57
+ // (ui/view/render.js configureReportRevalidation); `folded` where that left
58
+ // rows out.
59
+ export function liteWorkspace(reports, links) {
60
+ const fold = !managedTeamRevalidationConflicted(Object.assign(reports.map(({ filename, data }) => ({ filename, data })), { links }))
61
+ const lite = reports.map(report => ({ ...report, data: liteReportData(report.data, { fold }) }))
62
+ return { reports: lite, folded: fold && lite.some((report, index) => rows(report.data) < rows(reports[index].data)) }
32
63
  }
@@ -0,0 +1,2 @@
1
+ export function findingValueIndex(): { values: unknown[]; index<T>(data: T): T }
2
+ export function expandFindingValues<T>(data: T, values: unknown): T
@@ -0,0 +1,44 @@
1
+ // A finding's `package` and `repo`: the same few across a team's many
2
+ // findings. A team's reports are sent with each once, in the response's
3
+ // `values`, and each finding's as its index there
4
+ // (server-managed/team-reports.ts), put back as they are read
5
+ // (client/managed/session.js).
6
+ const FIELDS = ['package', 'repo']
7
+
8
+ const entriesKey = data => Array.isArray(data?.findings) ? 'findings' : Array.isArray(data?.groups) ? 'groups' : null
9
+
10
+ // `values`, and `index(data)`: a report's data with its findings' values as
11
+ // their index in it, one value at one index across every report it is given.
12
+ export function findingValueIndex() {
13
+ const indexes = new Map(), values = []
14
+ const indexOf = value => {
15
+ const key = JSON.stringify(value)
16
+ if (!indexes.has(key)) indexes.set(key, values.push(value) - 1)
17
+ return indexes.get(key)
18
+ }
19
+ const indexed = finding => {
20
+ if (!finding || typeof finding !== 'object' || !FIELDS.some(field => finding[field] != null)) return finding
21
+ const copy = { ...finding }
22
+ for (const field of FIELDS) if (finding[field] != null) copy[field] = indexOf(finding[field])
23
+ return copy
24
+ }
25
+ return {
26
+ values,
27
+ index(data) {
28
+ const key = entriesKey(data)
29
+ return key ? { ...data, [key]: data[key].map(entry => Array.isArray(entry) ? entry.map(indexed) : indexed(entry)) } : data
30
+ },
31
+ }
32
+ }
33
+
34
+ // A report's data, as read, its findings' values put in place of their index
35
+ // in a response's `values`; one sent without any as it is.
36
+ export function expandFindingValues(data, values) {
37
+ const key = entriesKey(data)
38
+ if (!key || !Array.isArray(values)) return data
39
+ for (const finding of data[key].flat()) {
40
+ if (!finding || typeof finding !== 'object') continue
41
+ for (const field of FIELDS) if (Number.isInteger(finding[field])) finding[field] = values[finding[field]]
42
+ }
43
+ return data
44
+ }
@@ -0,0 +1,2 @@
1
+ export function liteName(name: string): string
2
+ export function readLiteReportData<T>(data: T): T
@@ -0,0 +1,20 @@
1
+ import { reportEntries } from '@preventive/report'
2
+
3
+ // As much of a name as a heading takes (@preventive/report write-md-finding.js
4
+ // HEADING_MAX), about a board card's three lines. A board's copy of a team's
5
+ // reports (finding-detail.js) cuts one longer to end in `CUT`, an ellipsis
6
+ // once read.
7
+ const CUT = '...', NAME_CHARS = 120
8
+
9
+ export const liteName = name => name.length > NAME_CHARS ? `${name.slice(0, NAME_CHARS - CUT.length)}${CUT}` : name
10
+
11
+ // A report's data from its board's copy, as read: a name cut ends in an
12
+ // ellipsis. One as long as a cut leaves it and ending in `CUT` is one; one
13
+ // shorter ends as it does.
14
+ export function readLiteReportData(data) {
15
+ for (const finding of (reportEntries(data) ?? []).flat()) {
16
+ const name = finding?.description
17
+ if (typeof name === 'string' && name.length === NAME_CHARS && name.endsWith(CUT)) finding.description = `${name.slice(0, -CUT.length)}…`
18
+ }
19
+ return data
20
+ }
@@ -1,5 +1,7 @@
1
1
  export const MAX_TEAM_NPM_SCOPES: number
2
2
  export function isNpmPackageName(value: unknown): value is string
3
3
  export function isNpmPackageSpec(value: unknown): value is string
4
+ export function isPatchHash(value: unknown): value is string
5
+ export function isPatchPath(value: unknown): value is string
4
6
  export function npmPackageScope(name: string): string | null
5
7
  export function normalizeNpmScope(value: unknown): string | null
@@ -1,5 +1,6 @@
1
1
  // npm package names, versions and scopes as the managed npm viewer takes
2
- // them, shared by its routes, the server and the Teams page.
2
+ // them, and the pnpm patches its reviews read, shared by its routes, the
3
+ // server and the Teams page.
3
4
 
4
5
  // Upstream's rule (@preventive/upstream src/args.js): a scope, then a name
5
6
  // starting with none of `.`, `_` and `-`, 214 characters in all.
@@ -9,6 +10,9 @@ const PACKAGE_SPEC = /^(?=.{1,256}$)[\dA-Za-z][\w.+-]*$/u
9
10
  // npm's rule for a scope: lowercase, and not starting with `.` or `_`.
10
11
  const SCOPE = /^@[\da-z~-][\w.~-]*$/u
11
12
  export const MAX_TEAM_NPM_SCOPES = 100
13
+ // A pnpm patch's hash as a lockfile names it: a sha256 in hex, or pnpm 9's
14
+ // md5 in base32.
15
+ const PATCH_HASH = /^(?:[\da-f]{64}|[2-7a-z]{26})$/u
12
16
 
13
17
  export function isNpmPackageName(value) {
14
18
  return typeof value === 'string' && PACKAGE_NAME.test(value)
@@ -18,6 +22,19 @@ export function isNpmPackageSpec(value) {
18
22
  return typeof value === 'string' && PACKAGE_SPEC.test(value)
19
23
  }
20
24
 
25
+ export function isPatchHash(value) {
26
+ return typeof value === 'string' && PATCH_HASH.test(value)
27
+ }
28
+
29
+ // A path as GitHub's contents take it, as a pnpm patch's file is read
30
+ // (server-managed/npm-patch.ts) and a file of the package it changes is
31
+ // named: names with no empty, `.` or `..` one among them, nor control
32
+ // characters or backslashes.
33
+ export function isPatchPath(value) {
34
+ return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/[\p{Cc}\\]/u.test(value)
35
+ && value.split('/').every(part => part !== '' && part !== '.' && part !== '..')
36
+ }
37
+
21
38
  // `@scope` of a scoped name, lowercased as scopes are compared; null for an
22
39
  // unscoped one.
23
40
  export function npmPackageScope(name) {
@@ -1,7 +1,7 @@
1
1
  // Managed client pages only. API paths and E2E share hashes are not routes.
2
2
  import { BUNDLE_TABS } from '../bundle-tabs.js'
3
3
  import { MAX_FINDING_ID_LENGTH, isLinkableFindingId } from '../finding-id.js'
4
- import { isNpmPackageName, isNpmPackageSpec } from './npm-packages.js'
4
+ import { isNpmPackageName, isNpmPackageSpec, isPatchHash, isPatchPath } from './npm-packages.js'
5
5
 
6
6
  // How Compare reviews the changes, past its Overview: `code`, beside the
7
7
  // tree of changed files. A link names the mode after the bundle or version
@@ -96,17 +96,26 @@ export function managedRoutePath(route) {
96
96
  // Search, Compare or Treemap tab, `/npm/<name>[@<version or dist-tag>][/<tab>…]`. Code names
97
97
  // its file by number and its lines in the fragment, as a bundle's Code
98
98
  // does: `/code/<file>#L4`. Compare names the version compared with, then
99
- // its mode past the Overview: `/compare/<version>/code` or `/diff`.
99
+ // its mode past the Overview: `/compare/<version>/code` or `/diff`. Review
100
+ // names the version reviewed against, and, of a version as a pnpm patch
101
+ // changes it, the patch (`patch`): its hash, and where it is read, a
102
+ // repository by its id, a commit and its file's path there,
103
+ // `?patch=<hash>&repo=<repo id>&commit=<commit>&path=<path>`.
100
104
  export const NPM_TABS = new Set(['overview', 'code', 'search', 'compare', 'review', 'treemap'])
105
+ // A patch as the server reads one (server-managed/npm-patch.ts requestedPatch).
106
+ const isPatch = patch => isPatchHash(patch?.hash) && isRepoId(patch.repoId) && COMMIT.test(patch.commit ?? '') && isPatchPath(patch.path)
101
107
  function npmPackagePath(route) {
102
108
  if (route.packageName == null) return '/npm'
103
109
  const tab = route.bundleTab ?? 'overview'
104
110
  if (!isNpmPackageName(route.packageName) || (route.packageSpec != null && !isNpmPackageSpec(route.packageSpec)) || !NPM_TABS.has(tab)
105
111
  || (route.compareSpec != null && !isNpmPackageSpec(route.compareSpec))) return null
106
112
  if (tab === 'review') {
107
- if (route.reviewPackage != null && !isNpmPackageName(route.reviewPackage) || route.reviewSpec != null && !isNpmPackageSpec(route.reviewSpec)) return null
113
+ if (route.reviewPackage != null && !isNpmPackageName(route.reviewPackage) || route.reviewSpec != null && !isNpmPackageSpec(route.reviewSpec)
114
+ || route.patch != null && !isPatch(route.patch)) return null
108
115
  const base = route.reviewPackage == null ? '' : `/${route.reviewPackage}${route.reviewSpec == null ? '' : `@${route.reviewSpec}`}`
109
- return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}/review${base}`
116
+ const patch = route.patch == null ? ''
117
+ : `?patch=${route.patch.hash}&repo=${route.patch.repoId}&commit=${route.patch.commit}&path=${route.patch.path.split('/').map(encodeURIComponent).join('/')}`
118
+ return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}/review${base}${patch}`
110
119
  }
111
120
  const file = tab === 'code' && isLineNumber(route.file) ? `/${route.file}` : ''
112
121
  const lines = file && isLineNumber(route.line)
@@ -115,15 +124,18 @@ function npmPackagePath(route) {
115
124
  return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}${tab === 'overview' ? '' : `/${tab}`}${file}${compare}${lines}`
116
125
  }
117
126
 
118
- function parseNpmRoute(path, hash) {
127
+ function parseNpmRoute(path, hash, search) {
119
128
  const review = /^\/npm\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?\/review(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?)?$/u.exec(path)
120
129
  if (review) {
121
130
  if (!isNpmPackageName(review[1]) || review[2] != null && !isNpmPackageSpec(review[2])
122
131
  || review[3] != null && !isNpmPackageName(review[3]) || review[4] != null && !isNpmPackageSpec(review[4])) return null
123
132
  // The full dependency form is canonical; a bare version is a shortcut.
124
133
  const versionOnly = review[3] && /^\d+\.\d+\.\d+(?:[-+].*)?$/u.test(review[3]) && review[4] == null
134
+ const patch = search.has('patch')
135
+ ? { hash: search.get('patch'), repoId: /^[1-9]\d*$/u.test(search.get('repo') ?? '') ? Number(search.get('repo')) : null, commit: search.get('commit'), path: search.get('path') } : null
136
+ if (patch && !isPatch(patch)) return null
125
137
  return { view: 'npm', packageName: review[1], packageSpec: review[2] ?? null, bundleTab: 'review',
126
- ...(review[3] ? { reviewPackage: versionOnly ? review[1] : review[3], reviewSpec: versionOnly ? review[3] : review[4] ?? null } : {}) }
138
+ ...(review[3] ? { reviewPackage: versionOnly ? review[1] : review[3], reviewSpec: versionOnly ? review[3] : review[4] ?? null } : {}), ...(patch ? { patch } : {}) }
127
139
  }
128
140
  const match = /^\/npm(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(?:\/(code|search|compare|treemap)(?:\/([^/]+)(?:\/([a-z]+))?)?)?)?$/u.exec(path)
129
141
  if (!match) return undefined
@@ -256,7 +268,7 @@ export function parseManagedRoute(url) {
256
268
  if (bundleTab !== 'code' || bundle[5] || !/^[1-9]\d*$/u.test(bundle[4]) || !Number.isSafeInteger(file)) return null
257
269
  return { ...route, file, ...codeLines(url.hash) }
258
270
  }
259
- const npm = parseNpmRoute(path, url.hash)
271
+ const npm = parseNpmRoute(path, url.hash, url.searchParams)
260
272
  if (npm !== undefined) return npm
261
273
  const code = parseRepositoryCodeRoute(url)
262
274
  if (code !== undefined) return code
@@ -3,3 +3,4 @@
3
3
  export type TeamAppMetadata = { appMode: true; appFindings: number } | { appMode: false; appReports?: number; findings?: number }
4
4
  export function managedTeamAppMetadata(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] },
5
5
  options?: { countFindings?: boolean }): TeamAppMetadata
6
+ export function managedTeamRevalidationConflicted(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] }): boolean
@@ -2,6 +2,7 @@ import { inheritReportMeta, isAppFinding, reportEntries } from '@preventive/repo
2
2
  import { collectDuplicates } from '../../client/linked-findings.js'
3
3
  import { hasRevalidateStamp } from '../finding-filters.js'
4
4
  import { workspaceAppMetadata } from '../workspace-app.js'
5
+ import { mergeReportGroups } from '../workspace-groups.js'
5
6
 
6
7
  // The same classification as local workspaces, using only the complete,
7
8
  // server-filtered published workspace: the reports of one team response and
@@ -9,6 +10,21 @@ import { workspaceAppMetadata } from '../workspace-app.js'
9
10
  // catalog, so the sidebar opens a team collapsed to its App findings at once.
10
11
  // The server backfills finding IDs before building these envelopes.
11
12
  export function managedTeamAppMetadata(workspace, options = {}) {
13
+ const { reports, duplicates } = viewReports(workspace)
14
+ return workspaceAppMetadata(reports, id => [...duplicates.get(id) ?? []], options)
15
+ }
16
+
17
+ // Whether copies of one finding in the workspace disagree on what the
18
+ // revalidation pass said, which takes the App lens off a view of it
19
+ // (ui/view/render.js configureReportRevalidation). Asked of all its rows, so
20
+ // that a view of any of its reports holds none either.
21
+ export function managedTeamRevalidationConflicted(workspace) {
22
+ return mergeReportGroups(viewReports(workspace).reports).conflicts.size > 0
23
+ }
24
+
25
+ // The workspace's reports as a view holds them (ui/view/ingest.js), with the
26
+ // duplicates its links name.
27
+ function viewReports(workspace) {
12
28
  const duplicates = new Map(), reports = []
13
29
  collectDuplicates(workspace.links ?? [], duplicates)
14
30
  for (const { filename, data } of workspace) {
@@ -27,5 +43,5 @@ export function managedTeamAppMetadata(workspace, options = {}) {
27
43
  }))
28
44
  reports.push({ fileName: filename, groups })
29
45
  }
30
- return workspaceAppMetadata(reports, id => [...duplicates.get(id) ?? []], options)
46
+ return { reports, duplicates }
31
47
  }