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

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 (112) hide show
  1. package/common/bundle-compare-diff.js +28 -9
  2. package/common/bundle-compare-inputs.d.ts +1 -0
  3. package/common/bundle-compare-inputs.js +91 -15
  4. package/common/bundle-comparison.js +14 -7
  5. package/common/default-scan-models.ts +3 -0
  6. package/common/dependency-reviews.d.ts +1 -1
  7. package/common/dependency-reviews.js +1 -1
  8. package/common/managed/finding-detail.d.ts +4 -2
  9. package/common/managed/finding-detail.js +27 -15
  10. package/common/managed/team-app.d.ts +9 -0
  11. package/common/managed/team-app.js +59 -6
  12. package/common/workspace-app.js +38 -6
  13. package/out/activity.js +4 -4
  14. package/out/chunk-4EMM6UCD.js +1 -0
  15. package/out/chunk-4ISO5F7Y.js +7 -0
  16. package/out/chunk-4XIH7YTX.js +1 -0
  17. package/out/chunk-6W5BMATD.js +1 -0
  18. package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
  19. package/out/chunk-DEVAORP2.js +1 -0
  20. package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
  21. package/out/chunk-E3LFSUDJ.js +9 -0
  22. package/out/chunk-FHIVVVUA.js +6 -0
  23. package/out/chunk-G3LY2FV4.js +1 -0
  24. package/out/chunk-HAZKDK4M.js +4 -0
  25. package/out/chunk-HOB3YPS5.js +1 -0
  26. package/out/chunk-I47TYPXO.js +52 -0
  27. package/out/chunk-J7A5OSZE.js +244 -0
  28. package/out/chunk-JFL7WLRV.js +1 -0
  29. package/out/chunk-JMG5DXII.js +2 -0
  30. package/out/chunk-JOXLZXMI.js +1 -0
  31. package/out/chunk-L4CTEMRG.js +2 -0
  32. package/out/chunk-M37X4LCF.js +14 -0
  33. package/out/chunk-M3Y6SLC4.js +1 -0
  34. package/out/chunk-NXATZJQ2.js +1 -0
  35. package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
  36. package/out/chunk-PAD2JHZT.js +1 -0
  37. package/out/chunk-QMVOEBWC.js +13 -0
  38. package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
  39. package/out/chunk-RRBIJL2B.js +1 -0
  40. package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
  41. package/out/chunk-TOXMUOIW.js +5 -0
  42. package/out/chunk-UBOCLLF2.js +1 -0
  43. package/out/chunk-UCP3JRE2.js +1 -0
  44. package/out/{chunk-NCN3M5EP.js → chunk-VC72ZO3P.js} +2 -2
  45. package/out/chunk-VRX2APNA.js +9 -0
  46. package/out/{chunk-IMM274IW.js → chunk-VZMELPVL.js} +4 -3
  47. package/out/{chunk-CHRBUGVN.js → chunk-WHVXTJHK.js} +1 -1
  48. package/out/chunk-WMFDQS2L.js +11 -0
  49. package/out/chunk-WNSVBOSO.js +41 -0
  50. package/out/chunk-WREU7JAG.js +1 -0
  51. package/out/chunk-Y5ZHFNBD.js +2 -0
  52. package/out/chunk-YF4M5OCI.js +1 -0
  53. package/out/chunk-Z667U2UJ.js +1 -0
  54. package/out/chunk-ZQHLECTI.js +1 -0
  55. package/out/client-managed-import.js +1 -1
  56. package/out/client-managed.js +27 -26
  57. package/out/compare.js +14 -14
  58. package/out/dialogs.js +36 -51
  59. package/out/graph.js +17 -15
  60. package/out/index.html +26 -10
  61. package/out/prism.js +1 -1
  62. package/out/scan.js +2 -2
  63. package/out/terminal.js +232 -232
  64. package/out/view.css +1 -1
  65. package/out/view.js +15 -15
  66. package/package.json +12 -7
  67. package/risk/actions/resolve.js +36 -5
  68. package/risk/index.d.ts +20 -2
  69. package/risk/npm/malware.js +6 -4
  70. package/risk/npm/registry.js +8 -6
  71. package/server-managed/bundle-advisories.ts +56 -74
  72. package/server-managed/bundle-build-worker.js +61 -4
  73. package/server-managed/bundle-build.ts +13 -4
  74. package/server-managed/bundle-dependency-reviews.ts +74 -0
  75. package/server-managed/bundle-validation.ts +32 -0
  76. package/server-managed/db-methods.ts +51 -16
  77. package/server-managed/db-neon.ts +18 -2
  78. package/server-managed/db.ts +11 -2
  79. package/server-managed/github-issue-links.ts +151 -47
  80. package/server-managed/github-pulls.ts +34 -15
  81. package/server-managed/history-windows.ts +172 -0
  82. package/server-managed/http.ts +227 -12
  83. package/server-managed/index.ts +13 -1
  84. package/server-managed/managed-issues.ts +29 -15
  85. package/server-managed/npm-insights.ts +4 -3
  86. package/server-managed/npm-kept.ts +4 -0
  87. package/server-managed/package-entry-points.ts +37 -9
  88. package/server-managed/pretty-print.ts +7 -5
  89. package/server-managed/public-workspace.ts +2 -2
  90. package/server-managed/repository-actions.ts +7 -4
  91. package/server-managed/repository-browser.ts +11 -4
  92. package/server-managed/repository-history.ts +2 -2
  93. package/server-managed/repository-pulse.ts +11 -2
  94. package/server-managed/repository-risk-audit.ts +4 -5
  95. package/server-managed/repository-risk.ts +4 -4
  96. package/server-managed/sql.ts +2 -1
  97. package/server-managed/stasis-config.ts +46 -0
  98. package/server-managed/team-app-summaries.ts +54 -0
  99. package/server-managed/team-app.ts +52 -31
  100. package/server-managed/team-reports.ts +34 -13
  101. package/server-managed/upstream-cache.ts +41 -32
  102. package/server-managed/workspace-shares.ts +3 -1
  103. package/out/chunk-2ZDZWLFY.js +0 -1
  104. package/out/chunk-7VAWINQF.js +0 -6
  105. package/out/chunk-FMXWXDR4.js +0 -36
  106. package/out/chunk-IZ7POWI3.js +0 -18
  107. package/out/chunk-LJ7XRXFU.js +0 -52
  108. package/out/chunk-OGAYMEER.js +0 -232
  109. package/out/chunk-QB4CBOX2.js +0 -1
  110. package/out/chunk-TQJDYHVE.js +0 -2
  111. package/out/chunk-VH4XMZIW.js +0 -2
  112. package/out/chunk-WYLQWYLA.js +0 -1
@@ -25,8 +25,12 @@ import { resolutionKey } from './bundle-compare-inputs.js'
25
25
 
26
26
  // Resolutions compare independently from file bytes: identical files can be
27
27
  // wired together differently. Inputs are keyed by importer, specifier,
28
- // conditions (including import attributes), and platform. Only existing
29
- // resolutions whose targets changed are reported; additions/removals are omitted.
28
+ // conditions (including import attributes), and platform. Resolutions whose
29
+ // targets changed are reported (`changed`, repointed); one recorded on a side
30
+ // alone (`onlyBase`, `onlyOther`) only where its importer holds the same
31
+ // contents on both sides (`identical`, of its path as the other bundle lays
32
+ // it out), as a file whose code changed gains and drops imports with it.
33
+ // Each row names its target on either side, null on the side without one.
30
34
  // `renames` (base path → other path, see renamesOf) names base files as
31
35
  // the other bundle does: a renamed importer's resolutions pair with its new
32
36
  // path's, reported under it, and a target renamed is the same target.
@@ -34,21 +38,36 @@ import { resolutionKey } from './bundle-compare-inputs.js'
34
38
  // the bundles' roots differ, a base target shown as the other bundle would
35
39
  // lay it out; a path no file has (an importer the bundle didn't capture, a
36
40
  // builtin) may read either as it is or through the root.
37
- export function computeResolutionDiff(base, other, renames = new Map(), basePaths = new Map(), root = null) {
41
+ export function computeResolutionDiff(base, other, renames = new Map(), basePaths = new Map(), root = null, identical = () => false) {
38
42
  const names = path => {
39
43
  const known = renames.get(path) ?? basePaths.get(path)
40
44
  return known === undefined ? [...new Set([path, through(root, path) ?? path])] : [known]
41
45
  }
42
- const changed = []
46
+ const changed = [], onlyBase = [], onlyOther = []
47
+ const paired = new Set()
43
48
  for (const before of base.values()) {
44
49
  const after = names(before.parent).map(parent => other.get(resolutionKey(parent, before.specifier, before.conditions, before.platform))).find(Boolean)
45
- if (after && !names(before.target).includes(after.target)) {
46
- const { target: otherTarget, ...identity } = after
47
- changed.push({ ...identity, baseTarget: basePaths.get(before.target) ?? before.target, otherTarget })
50
+ const baseTarget = basePaths.get(before.target) ?? before.target
51
+ if (after) {
52
+ paired.add(after.key)
53
+ if (!names(before.target).includes(after.target)) {
54
+ const { target: otherTarget, ...identity } = after
55
+ changed.push({ ...identity, baseTarget, otherTarget })
56
+ }
57
+ continue
48
58
  }
59
+ const parent = names(before.parent).find(identical)
60
+ if (parent === undefined) continue
61
+ const { specifier, conditions, platform } = before
62
+ onlyBase.push({ key: resolutionKey(parent, specifier, conditions, platform), parent, specifier, conditions, platform, baseTarget, otherTarget: null })
63
+ }
64
+ for (const after of other.values()) {
65
+ if (paired.has(after.key) || !identical(after.parent)) continue
66
+ const { target: otherTarget, ...identity } = after
67
+ onlyOther.push({ ...identity, baseTarget: null, otherTarget })
49
68
  }
50
- changed.sort((a, b) => a.key.localeCompare(b.key))
51
- return { changed, totalChanges: changed.length }
69
+ for (const rows of [changed, onlyBase, onlyOther]) rows.sort((a, b) => a.key.localeCompare(b.key))
70
+ return { changed, onlyBase, onlyOther, totalChanges: changed.length + onlyBase.length + onlyOther.length }
52
71
  }
53
72
 
54
73
  // A file's size, by the measure the Overview and Treemap use: text by the
@@ -1,2 +1,3 @@
1
1
  import type { BundleDetails } from './bundle-metadata.js'
2
2
  export function bundleCompareFiles(details: BundleDetails, scope?: string | null): Map<string, unknown>
3
+ export function bundleCompareSideScopes(base: BundleDetails, other: BundleDetails, scope?: string | null): [string, string]
@@ -1,31 +1,107 @@
1
1
  import { bundleFilesAsMap } from './bundle-sources.js'
2
2
 
3
- // Use the union of named trees. A tree absent from one stasis bundle contains
4
- // no files there; falling back to all files would invent additions/removals.
5
- // A bundle that names no trees (a sourcemap) is compared whole under any of
6
- // them. Unlike a single-bundle graph, retain a scope that includes every file
3
+ // A side's files, and the named trees holding any of them, with those they hold.
4
+ function namedTrees(details) {
5
+ const files = bundleFilesAsMap(details), trees = new Map()
6
+ for (const [name, paths] of Object.entries(details?.bundle?.reason ?? {})) {
7
+ const present = new Set(Array.isArray(paths) ? paths.filter(path => files.has(path)) : [])
8
+ if (present.size > 0) trees.set(name, present)
9
+ }
10
+ return { stasis: details?.kind === 'stasis', files, trees }
11
+ }
12
+
13
+ // Trees two sides name apart for the same files: a side naming the second of
14
+ // a pair and not the first, the other the first and not the second, has it
15
+ // compared as the first. A static build names the Metro bundle's `bundle:metro`
16
+ // and the rest `bundle`, as a capture names them `metro` and `run`: `bundle` is
17
+ // `run` only where `bundle:metro` is `metro`, on the same side.
18
+ const ALIASES = [['extra', 'add'], ['metro', 'bundle:metro']]
19
+ function aliased(sides) {
20
+ if (sides.length !== 2) return sides
21
+ const apart = (name, alias) => sides.find((side, i) => side.trees.has(alias) && !side.trees.has(name)
22
+ && sides[1 - i].trees.has(name) && !sides[1 - i].trees.has(alias))
23
+ const renames = sides.map(() => new Map())
24
+ for (const [name, alias] of ALIASES) {
25
+ const side = apart(name, alias)
26
+ if (side) renames[sides.indexOf(side)].set(alias, name)
27
+ }
28
+ const built = apart('metro', 'bundle:metro')
29
+ if (built && apart('run', 'bundle') === built) renames[sides.indexOf(built)].set('bundle', 'run')
30
+ // `actual`: the name a side gives a tree it is compared as another by.
31
+ return sides.map((side, i) => renames[i].size === 0 ? side : { ...side,
32
+ trees: new Map([...side.trees].map(([name, paths]) => [renames[i].get(name) ?? name, paths])),
33
+ actual: new Map([...renames[i]].map(([alias, name]) => [name, alias])) })
34
+ }
35
+
36
+ // The one tree of a stasis side naming every file it has (its own selector
37
+ // hidden, as common/bundle-reasons.js hides it), where no other side names it:
38
+ // such a side is compared within that tree under any of the others' trees.
39
+ function soleTree(side, sides) {
40
+ if (!side.stasis || side.trees.size !== 1) return null
41
+ const [[name, paths]] = side.trees
42
+ return paths.size === side.files.size && !sides.some(other => other !== side && other.trees.has(name)) ? name : null
43
+ }
44
+
45
+ // A side naming no tree, or one alone holding all its files.
46
+ const trivial = side => side.trees.size === 0 || (side.trees.size === 1 && [...side.trees.values()][0].size === side.files.size)
47
+
48
+ // A tree of each side the other names none of, compared with each other, as
49
+ // one scope: the same whichever side is the base, as a swap keeps it.
50
+ const PAIR = 'reasons:'
51
+ const pairScope = (first, second) => `${PAIR}${JSON.stringify([first, second].toSorted())}`
52
+ function pairOf(scope) {
53
+ if (!scope.startsWith(PAIR)) return null
54
+ try {
55
+ const pair = JSON.parse(scope.slice(PAIR.length))
56
+ return Array.isArray(pair) && pair.length === 2 && pair.every(name => typeof name === 'string') ? pair : null
57
+ } catch { return null }
58
+ }
59
+
60
+ // Use the union of named trees, each as its side is compared (aliased). A tree
61
+ // absent from one stasis bundle contains no files there; falling back to all
62
+ // files would invent additions/removals. A bundle that names no trees (a
63
+ // sourcemap) is compared whole under any of them, as is one whose sole tree
64
+ // holds all its files where the other names none of it (soleTree), its tree
65
+ // left out. Where neither side is trivial and each names a tree the other
66
+ // doesn't, the trees both name are offered, and each of the first side's own
67
+ // paired with each of the second's (pairScope), labelled from the first to the
68
+ // second. Unlike a single-bundle graph, retain a scope that includes every file
7
69
  // on one side.
8
70
  export function bundleCompareScopes(...details) {
9
- const names = new Set()
10
- for (const item of details) {
11
- const files = bundleFilesAsMap(item)
12
- for (const [name, paths] of Object.entries(item?.bundle?.reason ?? {})) {
13
- if (Array.isArray(paths) && paths.some(path => files.has(path))) names.add(name)
71
+ const sides = aliased(details.map(namedTrees))
72
+ if (sides.length === 2 && !sides.some(trivial)) {
73
+ const [first, second] = sides.map(side => [...side.trees.keys()].toSorted())
74
+ const shared = first.filter(name => second.includes(name))
75
+ const [firstOwn, secondOwn] = [first, second].map(names => names.filter(name => !shared.includes(name)))
76
+ if (firstOwn.length > 0 && secondOwn.length > 0) {
77
+ return [...shared.map(name => ({ id: `reason:${name}`, label: name })),
78
+ ...firstOwn.flatMap(from => secondOwn.map(to => ({ id: pairScope(from, to), label: `${from} → ${to}` })))]
14
79
  }
15
80
  }
81
+ const scoped = sides.filter(side => soleTree(side, sides) === null)
82
+ const names = new Set(scoped.flatMap(side => [...side.trees.keys()]))
16
83
  // A scope is only useful as a filter if it changes the files compared on
17
84
  // some side. Keep every scope when at least one does, otherwise hide the
18
85
  // selector, as single-bundle views do (see common/bundle-reasons.js).
19
- const narrows = name => details.some(item => {
20
- if (item?.kind !== 'stasis') return false
21
- const files = bundleFilesAsMap(item)
22
- const paths = item?.bundle?.reason?.[name]
23
- return new Set(Array.isArray(paths) ? paths.filter(path => files.has(path)) : []).size < files.size
24
- })
86
+ const narrows = name => scoped.some(side => side.stasis && (side.trees.get(name)?.size ?? 0) < side.files.size)
25
87
  if (![...names].some(narrows)) return []
26
88
  return [...names].toSorted().map(name => ({ id: `reason:${name}`, label: name }))
27
89
  }
28
90
 
91
+ // Each side's scope in a comparison within `scope`: within the tree of a pair
92
+ // (pairScope) it names; a side whose sole tree holds all its files, the other
93
+ // naming none of it (soleTree), within that tree; each within the tree it
94
+ // names as it is compared (aliased).
95
+ export function bundleCompareSideScopes(base, other, scope = '') {
96
+ if (!scope) return ['', '']
97
+ const pair = pairOf(scope), sides = aliased([base, other].map(namedTrees))
98
+ const named = scope.startsWith('reason:') ? scope.slice('reason:'.length) : null
99
+ return sides.map(side => {
100
+ const name = pair ? pair.find(tree => side.trees.has(tree)) : soleTree(side, sides) ?? named
101
+ return name == null ? scope : `reason:${side.actual?.get(name) ?? name}`
102
+ })
103
+ }
104
+
29
105
  export function bundleCompareFiles(details, scope = '') {
30
106
  const files = bundleFilesAsMap(details)
31
107
  if (!scope || details?.kind !== 'stasis') return files
@@ -10,7 +10,7 @@ import { bundlePkgOf } from './bundle-pkg-of.js'
10
10
  import { bundlePackageDirs, bundlePackageVersions } from './bundle-sources.js'
11
11
  import { bundleFileMeta, computeBundleDiff, computeResolutionDiff } from './bundle-compare-diff.js'
12
12
  import { comparePackages, computeVersionUpdates } from './bundle-compare-versions.js'
13
- import { bundleCompareFiles, bundleCompareResolutions } from './bundle-compare-inputs.js'
13
+ import { bundleCompareFiles, bundleCompareResolutions, bundleCompareSideScopes } from './bundle-compare-inputs.js'
14
14
  import { bundleLineCounts } from './bundle-metadata.js'
15
15
 
16
16
  export function prepareComparison(base, other, scope = null) {
@@ -19,8 +19,10 @@ export function prepareComparison(base, other, scope = null) {
19
19
  // images differ, and total less than the Overview does.
20
20
  // From the bundle picked to the open one.
21
21
  const [from, to] = [base, other]
22
- const baseSources = bundleCompareFiles(from, scope)
23
- const otherSources = bundleCompareFiles(to, scope)
22
+ // Each side within its scope (bundleCompareSideScopes).
23
+ const [baseScope, otherScope] = bundleCompareSideScopes(from, to, scope)
24
+ const baseSources = bundleCompareFiles(from, baseScope)
25
+ const otherSources = bundleCompareFiles(to, otherScope)
24
26
  // Classify original paths and preserve recorded Stasis package
25
27
  // boundaries from either side, as the open one lays its files out
26
28
  // where their roots differ. Own source has one identity.
@@ -36,12 +38,17 @@ export function prepareComparison(base, other, scope = null) {
36
38
  for (const row of diff.files.onlyBase) row.lines = baseLines.get(row.basePath ?? row.path) ?? 0
37
39
  for (const row of diff.files.onlyOther) row.lines = otherLines.get(row.path) ?? 0
38
40
  for (const row of diff.files.changed) Object.assign(row, { baseLines: baseLines.get(row.basePath ?? row.path) ?? 0, otherLines: otherLines.get(row.path) ?? 0 })
41
+ // A file both sides hold with the same contents, renamed or recorded
42
+ // otherwise or not: an import resolution one side records alone is told
43
+ // only of these.
44
+ const differs = new Set([...diff.files.onlyOther, ...diff.files.changed.filter(row => row.modified !== false)].map(row => row.path))
39
45
  diff.resolutions = computeResolutionDiff(
40
- bundleCompareResolutions(from, scope),
41
- bundleCompareResolutions(to, scope),
46
+ bundleCompareResolutions(from, baseScope),
47
+ bundleCompareResolutions(to, otherScope),
42
48
  new Map(diff.files.changed.filter(row => row.renamedFrom != null).map(row => [row.basePath, row.path])),
43
49
  diff.files.basePaths,
44
50
  diff.files.root,
51
+ path => otherSources.has(path) && !differs.has(path),
45
52
  )
46
53
  // Dependency versions come from the stasis per-module `{ name,
47
54
  // version }` metadata, not the path/byte walk above, so they're
@@ -51,8 +58,8 @@ export function prepareComparison(base, other, scope = null) {
51
58
  // alone.
52
59
  // Keyed by install directory, as the sizes are (`bundlePkgOf`).
53
60
  const versionKey = dir => bundlePkgOf('', { packageDir: dir })
54
- const baseVersions = bundlePackageVersions(from, scope ? baseSources.keys() : null, versionKey)
55
- const otherVersions = bundlePackageVersions(to, scope ? otherSources.keys() : null, versionKey)
61
+ const baseVersions = bundlePackageVersions(from, baseScope ? baseSources.keys() : null, versionKey)
62
+ const otherVersions = bundlePackageVersions(to, otherScope ? otherSources.keys() : null, versionKey)
56
63
  diff.versionUpdates = computeVersionUpdates(baseVersions, otherVersions)
57
64
  diff.packageRows = comparePackages(diff.packages, baseVersions, otherVersions)
58
65
  // The reason scope its files are within, for what the server works out of
@@ -10,9 +10,12 @@ export function defaultScanModels(provider: string | null = null) {
10
10
  'openai/gpt-6-astra-pro',
11
11
  'openai/gpt-6.1-sol',
12
12
  'openai/gpt-6.1-sol-pro',
13
+ 'openai/gpt-6.1-terra',
14
+ 'openai/gpt-6.1-terra-pro',
13
15
  'openai/gpt-6-luna',
14
16
  'openai/gpt-6-luna-pro',
15
17
  'moonshotai/kimi-k3',
18
+ 'moonshotai/kimi-k3.1-pro',
16
19
  'google/gemini-4-argon',
17
20
  'google/gemma-4-31b-it',
18
21
  'x-ai/grok-4.7',
@@ -12,7 +12,7 @@ export function allowedReviewVerdicts(entries: { file: string; status: ReviewVer
12
12
  interface ReviewNpmVersion { version: string; private?: boolean; manifest?: Record<string, unknown> }
13
13
  export function npmTakenDown(entry: { npm: ReviewNpmVersion }): boolean
14
14
  export function npmReviewVerdictLimit(npm: ReviewNpmVersion | null | undefined,
15
- advisoryList?: { versions: string[]; advisories: { id?: string; title?: string; severity?: string; cwe?: string[]; affected: number[] }[] } | null,
15
+ advisoryList?: { versions: string[]; advisories: { id?: string; title?: string; severity?: string; cwe?: string[]; malware?: boolean; affected: number[] }[] } | null,
16
16
  socket?: { alerts: { type: string }[] } | null, risk?: { findings: { id: string }[] } | null): ReviewVerdict
17
17
  export function needsPackageJsonReview(entries: { file: string; status: ReviewVerdict | null }[], files: string[], nonTextFiles?: string[] | null): boolean
18
18
  export function parseReviewNotes(comment: string): { text: string; bullet: boolean }[]
@@ -47,7 +47,7 @@ export function npmReviewVerdictLimit(npm, advisoryList, socket, risk) {
47
47
  const advisories = index < 0 ? [] : advisoryList.advisories.filter(advisory => advisory.affected.includes(index))
48
48
  const alerts = npm.private ? [] : socket?.alerts ?? []
49
49
  if (risk?.findings?.some(finding => finding.id === 'malware') || npmTakenDown({ npm }) || alerts.some(alert => alert.type === 'malware')
50
- || advisories.some(advisory => npmMalwareAdvisory(advisory) || advisory.id?.startsWith('MAL-') || /^Malicious package\b/iu.test(advisory.title ?? ''))) return 'reject'
50
+ || advisories.some(advisory => advisory.malware === true || npmMalwareAdvisory(advisory) || advisory.id?.startsWith('MAL-') || /^Malicious package\b/iu.test(advisory.title ?? ''))) return 'reject'
51
51
  if (advisories.some(advisory => advisory.severity === 'critical') || alerts.some(alert => alert.type === 'criticalCVE')) return 'request-changes'
52
52
  return 'accept'
53
53
  }
@@ -1,3 +1,5 @@
1
1
  export const FINDING_DETAIL_FIELDS: readonly string[]
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 }
2
+ export function liteReportData(data: unknown, options?: { fold?: boolean; shown?: ReadonlySet<string> | null }): unknown
3
+ export function liteWorkspace<T extends { filename: string; data: unknown }>(reports: readonly T[], links: readonly (readonly string[])[], view?: import('./team-app.js').ManagedTeamView): {
4
+ reports: T[]; folded: false | 'rows' | 'app'; lens: { drop: boolean; detail: boolean; upstream: boolean } | null
5
+ }
@@ -1,5 +1,5 @@
1
1
  import { findingTitle, reportEntries, revalidateKindOf } from '@preventive/report'
2
- import { managedTeamRevalidationConflicted } from './team-app.js'
2
+ import { managedTeamView } from './team-app.js'
3
3
  import { liteName } from './lite-name.js'
4
4
 
5
5
  // A finding's detail: what its popup, the views besides the board and an
@@ -8,7 +8,8 @@ import { liteName } from './lite-name.js'
8
8
  // and an export tell (write-md-finding.js PLAIN_FIELDS, CODE_FIELDS, but for
9
9
  // `priority`, which the board sorts and filters by), and the run meta its
10
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
11
+ // the board's filters read, stay), and the link the report gave its location
12
+ // (`location`), which its card does not draw. A team's
12
13
  // reports are first sent without it (server-managed/team-reports.ts), the
13
14
  // board drawn from them, and their findings filled in from the whole reports
14
15
  // (ui/view/managed-details.js).
@@ -16,7 +17,7 @@ export const FINDING_DETAIL_FIELDS = Object.freeze([
16
17
  'description', 'evidence', 'impact', 'reproduction', 'recommendation', 'confidenceReason', 'revalidateVerdict', 'revalidateRecommendation',
17
18
  'fileHash', 'exportName', 'methodName',
18
19
  'severityReason', 'commitHash', 'auditedCommit', 'detectedAt', 'committedAt', 'dateCreated', 'status', 'branch', 'pocStatus', 'parent', 'slug', 'reportPath',
19
- 'category', 'discoveredIn', 'effort', 'exportsMode',
20
+ 'category', 'discoveredIn', 'effort', 'exportsMode', 'location',
20
21
  ])
21
22
  const DETAIL = new Set(FINDING_DETAIL_FIELDS)
22
23
  // A finding without its detail. One without a title of its own is named by its
@@ -37,14 +38,20 @@ const isPass = finding => revalidateKindOf(finding) === 'revalidation'
37
38
  // App mode shows of an entry holding the revalidation pass's row that row
38
39
  // alone (ui/view/group.js foldedTabs): with `fold`, such an entry is sent as
39
40
  // 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 } = {}) {
41
+ // (ui/view/managed-details.js). With `shown`, the IDs of the findings one
42
+ // whose App view locks Confirmed shows (common/workspace-app.js
43
+ // workspaceAppRows), those alone are sent.
44
+ export function liteReportData(data, { fold = false, shown = null } = {}) {
42
45
  const key = Array.isArray(data?.findings) ? 'findings' : Array.isArray(data?.groups) ? 'groups' : null
43
46
  if (!key) return data
47
+ if (shown) {
48
+ const kept = entry => (Array.isArray(entry) ? entry : [entry]).filter(finding => shown.has(finding?.id))
49
+ return { ...data, [key]: data[key].map(kept).filter(entry => entry.length > 0).map(entry => entry.map(liteFinding)) }
50
+ }
44
51
  const entries = data[key].map(entry => {
45
52
  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)
53
+ const folded = fold && entry.length > 1 && entry.some(isPass) ? entry.filter(isPass) : entry
54
+ return folded.map(liteFinding)
48
55
  })
49
56
  return { ...data, [key]: entries }
50
57
  }
@@ -52,12 +59,17 @@ export function liteReportData(data, { fold = false } = {}) {
52
59
  const rows = data => (reportEntries(data) ?? []).flat().length
53
60
 
54
61
  // 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)) }
62
+ // each `liteReportData`: where the workspace's App view locks Confirmed, only
63
+ // the rows that view shows (`folded: 'app'`); else, where nothing in it
64
+ // contradicts the revalidation pass, which opens a view of it in App mode
65
+ // (ui/view/render.js configureReportRevalidation), its entries folded under
66
+ // their App rows (`folded: 'rows'`). Folded, the `lens` the rows left out
67
+ // tell (team-app.js managedTeamLens). All of it read from `view`, the
68
+ // workspace's (team-app.js managedTeamView), where its builder has one.
69
+ export function liteWorkspace(reports, links, view = managedTeamView(Object.assign(reports.map(({ filename, data }) => ({ filename, data })), { links }))) {
70
+ const shown = view.rows
71
+ const fold = shown !== null || !view.conflicted()
72
+ const lite = reports.map(report => ({ ...report, data: liteReportData(report.data, { fold, shown }) }))
73
+ if (!fold || !lite.some((report, index) => rows(report.data) < rows(reports[index].data))) return { reports: lite, folded: false, lens: null }
74
+ return { reports: lite, folded: shown ? 'app' : 'rows', lens: view.lens() }
63
75
  }
@@ -4,3 +4,12 @@ export type TeamAppMetadata = { appMode: true; appFindings: number } | { appMode
4
4
  export function managedTeamAppMetadata(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] },
5
5
  options?: { countFindings?: boolean }): TeamAppMetadata
6
6
  export function managedTeamRevalidationConflicted(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] }): boolean
7
+ export function managedTeamAppRows(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] }): Set<string> | null
8
+ export function managedTeamLens(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] }): { drop: boolean; detail: boolean; upstream: boolean }
9
+ export interface ManagedTeamView {
10
+ app: TeamAppMetadata
11
+ rows: Set<string> | null
12
+ conflicted(): boolean
13
+ lens(): { drop: boolean; detail: boolean; upstream: boolean }
14
+ }
15
+ export function managedTeamView(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] }): ManagedTeamView
@@ -1,7 +1,8 @@
1
- import { inheritReportMeta, isAppFinding, reportEntries } from '@preventive/report'
1
+ import { inheritReportMeta, isAppFinding, reportEntries, revalidateKindOf } from '@preventive/report'
2
2
  import { collectDuplicates } from '../../client/linked-findings.js'
3
+ import { dependencyDirectory, isDependencyFile } from '../../client/dependency-paths.js'
3
4
  import { hasRevalidateStamp } from '../finding-filters.js'
4
- import { workspaceAppMetadata } from '../workspace-app.js'
5
+ import { workspaceApp, workspaceAppMetadata, workspaceAppRows } from '../workspace-app.js'
5
6
  import { mergeReportGroups } from '../workspace-groups.js'
6
7
 
7
8
  // The same classification as local workspaces, using only the complete,
@@ -22,11 +23,63 @@ export function managedTeamRevalidationConflicted(workspace) {
22
23
  return mergeReportGroups(viewReports(workspace).reports).conflicts.size > 0
23
24
  }
24
25
 
26
+ // The IDs of the findings the workspace's basic App view can show
27
+ // (common/workspace-app.js workspaceAppRows), or null.
28
+ export function managedTeamAppRows(workspace) {
29
+ const { reports, duplicates } = viewReports(workspace)
30
+ return workspaceAppRows(reports, id => [...duplicates.get(id) ?? []])
31
+ }
32
+
33
+ // What a view of the whole workspace offers beside the App view
34
+ // (ui/view/render.js), told by all its rows: whether the revalidation layer
35
+ // can come off (`drop`), whether its detail stop has rows to unfold
36
+ // (`detail`), and whether an upstream lens has ground (`upstream`).
37
+ export function managedTeamLens(workspace) {
38
+ const { reports } = viewReports(workspace)
39
+ return lensOf(reports, mergeReportGroups(reports).groups)
40
+ }
41
+
42
+ // One reading of a team's published workspace for all that is asked of it as
43
+ // its reports are built (server-managed/team-reports.ts), each worked out of
44
+ // the same rows: its classification (managedTeamAppMetadata, its findings
45
+ // counted, and of a team not App, how many of its reports open in App mode on
46
+ // their own), the rows its App view shows (managedTeamAppRows), and, asked
47
+ // only where wanted, whether copies contradict the pass
48
+ // (managedTeamRevalidationConflicted) and what its rows offer the toolbar
49
+ // (managedTeamLens).
50
+ export function managedTeamView(workspace) {
51
+ const { reports, duplicates, linked } = viewReports(workspace)
52
+ const { metadata, rows } = workspaceApp(reports, id => [...duplicates.get(id) ?? []], { countFindings: true })
53
+ // Each report alone, with the links the workspace has, not its link reports'.
54
+ const alone = id => [...linked.get(id) ?? []]
55
+ const app = metadata.appMode ? metadata : { ...metadata, appReports: reports.filter(report => workspaceAppMetadata([report], alone).appMode).length }
56
+ let merged = null
57
+ const plain = () => merged ??= mergeReportGroups(reports)
58
+ return { app, rows, conflicted: () => plain().conflicts.size > 0, lens: () => lensOf(reports, plain().groups) }
59
+ }
60
+
61
+ const RULED_OUT = new Set(['refuted', 'unreachable'])
62
+
63
+ function lensOf(reports, groups) {
64
+ const rows = reports.flatMap(report => report.groups.flat())
65
+ const directory = dependencyDirectory(reports)
66
+ const detail = groups.some(group => {
67
+ const kinds = group.map(revalidateKindOf)
68
+ return kinds.includes('partial') || kinds.includes('revalidation') && kinds.some(kind => kind !== 'revalidation' && !RULED_OUT.has(kind))
69
+ })
70
+ return {
71
+ drop: rows.some(hasRevalidateStamp),
72
+ detail,
73
+ upstream: rows.some(f => f.isUpstream === true || f.isApp === false && !('isUpstream' in f) && isDependencyFile(f.file, directory)),
74
+ }
75
+ }
76
+
25
77
  // The workspace's reports as a view holds them (ui/view/ingest.js), with the
26
- // duplicates its links name.
78
+ // duplicates its links name, and those its own `links` name alone (`linked`).
27
79
  function viewReports(workspace) {
28
- const duplicates = new Map(), reports = []
80
+ const duplicates = new Map(), linked = new Map(), reports = []
29
81
  collectDuplicates(workspace.links ?? [], duplicates)
82
+ collectDuplicates(workspace.links ?? [], linked)
30
83
  for (const { filename, data } of workspace) {
31
84
  if (data.source === 'links' && Array.isArray(data.links)) {
32
85
  collectDuplicates(data.links, duplicates)
@@ -41,7 +94,7 @@ function viewReports(workspace) {
41
94
  finding._sourcePass = judged.has(finding._source)
42
95
  return finding
43
96
  }))
44
- reports.push({ fileName: filename, groups })
97
+ reports.push({ fileName: filename, groups, treeDependencies: data.treeDependencies ?? null })
45
98
  }
46
- return { reports, duplicates }
99
+ return { reports, duplicates, linked }
47
100
  }
@@ -1,5 +1,5 @@
1
1
  import { revalidateKindOf } from '@preventive/report'
2
- import { canLockConfirmed } from './finding-filters.js'
2
+ import { activeRevalidateKinds, canLockConfirmed } from './finding-filters.js'
3
3
  import { mergeReportGroups } from './workspace-groups.js'
4
4
  import { mergeLinkedWorkspaceGroups } from './linked-workspace-groups.js'
5
5
 
@@ -27,13 +27,45 @@ function onlyUnjudgedImports(groups) {
27
27
  // `countFindings` has a workspace that is not App count its rows too, after
28
28
  // the same merging, for a caller that shows it collapsed (managed teams).
29
29
  export function workspaceAppMetadata(reports, duplicatesOf = () => [], { countFindings = false } = {}) {
30
- const { groups, conflicts } = mergeReportGroups(reports, { hideRuledOut: true })
31
- const linked = mergeLinkedWorkspaceGroups(groups, duplicatesOf, appTabs)
32
- const notApp = countFindings ? { appMode: false, findings: linked.length } : { appMode: false }
33
- if (conflicts.size > 0) return notApp
34
- if (!onlyUnjudgedImports(linked) && !canLockConfirmed(linked, { tabs: appTabs, kindOf: revalidateKindOf, severityMode: 'corrected' })) return notApp
30
+ return metadataOf(appView(reports, duplicatesOf), countFindings)
31
+ }
32
+
33
+ // The IDs of the findings a workspace's basic App view can show: of each row
34
+ // its locked Confirmed shows (ui/view/filters.js filterRevalidateKind), the
35
+ // App tabs, the rest folded under them; none the pass ruled out. Null where it
36
+ // has no such view, or one with no outcome to lock, which shows every row.
37
+ export function workspaceAppRows(reports, duplicatesOf = () => []) {
38
+ return rowsOf(appView(reports, duplicatesOf))
39
+ }
40
+
41
+ // Both, of one merge of its rows.
42
+ export function workspaceApp(reports, duplicatesOf = () => [], { countFindings = false } = {}) {
43
+ const view = appView(reports, duplicatesOf)
44
+ return { metadata: metadataOf(view, countFindings), rows: rowsOf(view) }
45
+ }
46
+
47
+ function metadataOf({ linked, app }, countFindings) {
48
+ if (!app) return countFindings ? { appMode: false, findings: linked.length } : { appMode: false }
35
49
  return {
36
50
  appMode: true,
37
51
  appFindings: linked.filter((group) => appTabs(group).some((f) => f.isApp)).length,
38
52
  }
39
53
  }
54
+
55
+ function rowsOf({ linked, app }) {
56
+ if (!app || onlyUnjudgedImports(linked)) return null
57
+ const kinds = activeRevalidateKinds('confirmed', '')
58
+ const confirmed = (f) => kinds.includes(revalidateKindOf(f) || (f._source && !f._sourcePass ? 'revalidation' : ''))
59
+ const ids = new Set()
60
+ for (const group of linked) if (group.some(confirmed)) for (const f of appTabs(group)) ids.add(f.id)
61
+ return ids
62
+ }
63
+
64
+ // A workspace's rows as its basic App view merges them, and whether it has one.
65
+ function appView(reports, duplicatesOf) {
66
+ const { groups, conflicts } = mergeReportGroups(reports, { hideRuledOut: true })
67
+ const linked = mergeLinkedWorkspaceGroups(groups, duplicatesOf, appTabs)
68
+ const app = conflicts.size === 0
69
+ && (onlyUnjudgedImports(linked) || canLockConfirmed(linked, { tabs: appTabs, kindOf: revalidateKindOf, severityMode: 'corrected' }))
70
+ return { linked, app }
71
+ }