@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.38
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.
- package/README.md +16 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +6 -2
- package/common/bundle-metadata.js +63 -20
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-source-links.js +7 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +3 -1
- package/common/bundle-sources.js +47 -19
- package/common/code-language.js +16 -2
- package/common/default-scan-models.ts +2 -1
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/github-pr.ts +1 -1
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +1 -5
- package/common/managed/routes.js +123 -12
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +1 -1
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-5FKRIJGF.js +1 -0
- package/out/chunk-6Z5XLSPJ.js +1 -0
- package/out/chunk-ABW5FVSN.js +1 -0
- package/out/chunk-BJKRVROW.js +1 -0
- package/out/chunk-BYUEDJOM.js +7 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DCQX2J3C.js +223 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-F4OMDNEY.js +1 -0
- package/out/chunk-FU4MTW7F.js +1 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GMFXFEUA.js +2 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-HIV7SVI7.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-JAKWIZWL.js +2 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OJ5DGP5E.js +33 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-SD2WJAXN.js +48 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZPD5R5RP.js +3 -0
- package/out/client-managed-import.js +3 -87
- package/out/client-managed.js +26 -338
- package/out/client-sync.js +8 -14
- package/out/graph.js +17 -30
- package/out/index.html +34 -0
- package/out/prism.js +6 -2
- package/out/terminal.js +241 -257
- package/out/view.css +1 -1
- package/out/view.js +76 -520
- package/package.json +88 -7
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +164 -0
- package/risk/files/runs-code.js +329 -0
- package/risk/files/script.js +112 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +222 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +33 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-e2e/http.ts +7 -7
- package/server-e2e/npm-proxy.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
- package/server-e2e/objstore/handlers.ts +1 -1
- package/server-e2e/objstore/tokens.ts +1 -1
- package/server-e2e/sign.ts +1 -1
- package/server-managed/bundle-advisories.ts +68 -11
- package/server-managed/bundle-cache.ts +37 -4
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-summary-cache.ts +24 -10
- package/server-managed/config.ts +4 -0
- package/server-managed/db-methods.ts +98 -11
- package/server-managed/db-neon.ts +39 -4
- package/server-managed/db-schema.ts +14 -2
- package/server-managed/db-table-names.ts +1 -1
- package/server-managed/db.ts +7 -3
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-issue-oauth.ts +5 -6
- package/server-managed/github-metadata.ts +115 -0
- package/server-managed/github-oauth.ts +38 -2
- package/server-managed/http-response.ts +26 -0
- package/server-managed/http.ts +943 -52
- package/server-managed/index.ts +11 -5
- package/server-managed/kept.ts +28 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +78 -7
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/report-sources.ts +13 -2
- package/server-managed/repository-browser.ts +19 -5
- package/server-managed/repository-risk.ts +413 -0
- package/server-managed/session.ts +16 -2
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage-encryption.ts +5 -3
- package/server-managed/storage-payload.ts +4 -0
- package/server-managed/storage-stores.ts +2 -0
- package/server-managed/storage.ts +6 -3
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-feed.ts +1 -1
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +25 -4
- package/server-managed/upstream-cache.ts +147 -5
- package/server-managed/view-as.ts +58 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { npmMalwareAdvisory } from '../risk/npm/malware.js'
|
|
2
|
+
|
|
3
|
+
export const REVIEW_VERDICTS = {
|
|
4
|
+
accept: 'Accept',
|
|
5
|
+
'accept-with-nits': 'Accept with nits (non-blocking)',
|
|
6
|
+
'request-changes': 'Request changes',
|
|
7
|
+
reject: 'Reject',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Keep non-text files in the diff and its denominator. Verdicts for them are
|
|
11
|
+
// disabled for now, including when either side of a change is non-text.
|
|
12
|
+
export function isReviewableFile(before, after) {
|
|
13
|
+
return [before, after].every(content => content === undefined || typeof content === 'string')
|
|
14
|
+
&& [before, after].some(content => typeof content === 'string')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Publication saves all changed paths and their non-text subset. Null means
|
|
18
|
+
// an older snapshot without type evidence: it cannot certify full coverage.
|
|
19
|
+
export function summarizeReview(entries, files, nonTextFiles = []) {
|
|
20
|
+
const verdicts = Object.keys(REVIEW_VERDICTS)
|
|
21
|
+
const excluded = new Set(nonTextFiles ?? [])
|
|
22
|
+
const checks = entries.filter(entry => nonTextFiles !== null && !excluded.has(entry.file) && verdicts.includes(entry.status))
|
|
23
|
+
const verdictLimit = verdicts.findLast(verdict => checks.some(entry => entry.status === verdict)) ?? 'accept'
|
|
24
|
+
if (files === null) return { coverage: null, checkedFiles: null, totalFiles: null, verdictLimit }
|
|
25
|
+
const checked = new Set(checks.map(entry => entry.file))
|
|
26
|
+
const checkedFiles = files.filter(file => checked.has(file)).length
|
|
27
|
+
return { coverage: checkedFiles === files.length ? 'full' : 'partial', checkedFiles, totalFiles: files.length, verdictLimit }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function allowedReviewVerdicts(entries, limit = 'accept') {
|
|
31
|
+
const verdicts = Object.keys(REVIEW_VERDICTS)
|
|
32
|
+
return verdicts.slice(Math.max(verdicts.indexOf(limit), verdicts.indexOf(summarizeReview(entries, null).verdictLimit)))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// npm's security placeholder, identified by its provenance as well as its name.
|
|
36
|
+
export function npmTakenDown({ npm: { version, manifest = {} } }) {
|
|
37
|
+
const npms = manifest.description === 'security holding package' || manifest.github?.github?.toLowerCase() === 'npm/security-holder'
|
|
38
|
+
return npms && (manifest.publisher === 'npm' || /-security(?:\.\d+)?$/u.test(version))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only findings affecting the version being reviewed constrain its verdict.
|
|
42
|
+
// Socket's aggregate scores also fall for other reasons; use explicit alerts.
|
|
43
|
+
// Alert identifiers: https://docs.socket.dev/docs/rule-conditions
|
|
44
|
+
export function npmReviewVerdictLimit(npm, advisoryList, socket, risk) {
|
|
45
|
+
if (!npm) return 'accept'
|
|
46
|
+
const index = advisoryList?.versions?.indexOf(npm.version) ?? -1
|
|
47
|
+
const advisories = index < 0 ? [] : advisoryList.advisories.filter(advisory => advisory.affected.includes(index))
|
|
48
|
+
const alerts = npm.private ? [] : socket?.alerts ?? []
|
|
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'
|
|
51
|
+
if (advisories.some(advisory => advisory.severity === 'critical') || alerts.some(alert => alert.type === 'criticalCVE')) return 'request-changes'
|
|
52
|
+
return 'accept'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function needsPackageJsonReview(entries, files, nonTextFiles = []) {
|
|
56
|
+
return summarizeReview(entries, files, nonTextFiles).coverage === 'partial'
|
|
57
|
+
&& (nonTextFiles === null || nonTextFiles.includes('package.json')
|
|
58
|
+
|| !entries.some(entry => entry.file === 'package.json' && Object.keys(REVIEW_VERDICTS).includes(entry.status)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const markdownText = text => text.replaceAll(/[\\`*_{}[\]<>]/gu, '\\$&').replaceAll(/\r\n?/gu, '\n').replaceAll('\n', '\n ')
|
|
62
|
+
|
|
63
|
+
export function parseReviewNotes(comment) {
|
|
64
|
+
return comment.replaceAll(/\r\n?/gu, '\n').split(/(?:^|\n)[\t ]*\*[\t ]+/u)
|
|
65
|
+
.map((text, index) => ({ text: text.trim(), bullet: index > 0 })).filter(note => note.text)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function auditNotes(comment, prefix = '') {
|
|
69
|
+
return parseReviewNotes(comment).map(note => `* ${prefix}${markdownText(note.text)}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function auditMarkdown(review) {
|
|
73
|
+
if (review.publishedAt === null || review.revokedAt !== null || review.coverage !== 'full'
|
|
74
|
+
|| !['accept', 'accept-with-nits'].includes(review.status)) return null
|
|
75
|
+
const target = `${review.package} ${review.version} ${review.integrity}`
|
|
76
|
+
const dependency = review.basePackage === null ? target
|
|
77
|
+
: review.basePackage === review.package ? `${review.package} ${review.baseVersion} -> ${review.version} ${review.integrity}`
|
|
78
|
+
: `${review.basePackage} ${review.baseVersion} -> ${target}`
|
|
79
|
+
const notes = [...review.entries.filter(entry => entry.status).flatMap(entry => auditNotes(entry.comment, `In ${markdownText(entry.file)}: `)), ...auditNotes(review.comment)]
|
|
80
|
+
return `Audited:\n\`\`\`\n${dependency}\n\`\`\`${notes.length > 0 ? `\n\nNotes:\n${notes.join('\n')}` : ''}`
|
|
81
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { effectiveSeverity, revalidateKindOf } from '@preventive/report'
|
|
2
|
+
import { recordRevalidationCopies } from './revalidation-conflicts.js'
|
|
3
|
+
|
|
4
|
+
// Gap-fill a derived finding only within a mergeable row. Raw report copies
|
|
5
|
+
// remain intact so App and code modes can use different row partitions.
|
|
6
|
+
// Provenance stays with the first copy; corrected severity keeps per-report
|
|
7
|
+
// variants. Missing/null fields are gaps, never disagreements.
|
|
8
|
+
const KEEPS_ITS_OWN = new Set(['correctedSeverity', 'correctedSeverityReason', 'isApp', 'isUpstream', 'isSecurity', 'source'])
|
|
9
|
+
|
|
10
|
+
// Contradictory revalidate* answers disable App mode for this loaded set.
|
|
11
|
+
// Retain the first answer on the derived copy; the conflict dialog exposes all
|
|
12
|
+
// answers instead of presenting that arbitrary choice as the pass's verdict.
|
|
13
|
+
export function mergeDuplicateFields(survivor, dup) {
|
|
14
|
+
if (!survivor || !dup || survivor === dup) return false
|
|
15
|
+
let conflicted = false
|
|
16
|
+
for (const [key, value] of Object.entries(dup)) {
|
|
17
|
+
if (key.startsWith('_') || KEEPS_ITS_OWN.has(key)) continue
|
|
18
|
+
if (value === undefined || value === null) continue
|
|
19
|
+
// The stamp is compared as the app READS it, not as the file
|
|
20
|
+
// wrote it: the reader trims and case-folds, and answers "no
|
|
21
|
+
// stamp" for anything it doesn't recognise (@preventive/report/src/finding.js
|
|
22
|
+
// revalidateKindOf). So `confirmed` and ` Confirmed ` agree, and
|
|
23
|
+
// a value the app can't read is no answer at all — it neither
|
|
24
|
+
// blocks the other copy's real stamp from landing nor takes the
|
|
25
|
+
// layer off a whole workspace for a typo.
|
|
26
|
+
if (key === 'revalidate') {
|
|
27
|
+
const theirs = revalidateKindOf(dup)
|
|
28
|
+
if (!theirs) continue
|
|
29
|
+
const mine = revalidateKindOf(survivor)
|
|
30
|
+
if (!mine) survivor[key] = value
|
|
31
|
+
else if (mine !== theirs) conflicted = true
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
const own = survivor[key]
|
|
35
|
+
if (own === undefined || own === null) { survivor[key] = value; continue }
|
|
36
|
+
// The pass's prose either side of the stamp, compared past the
|
|
37
|
+
// whitespace two writers can differ on for the same words.
|
|
38
|
+
if (key.startsWith('revalidate') && String(own).trim() !== String(value).trim()) conflicted = true
|
|
39
|
+
}
|
|
40
|
+
return conflicted
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The row merger also retains the evidence behind a conflict. This must
|
|
44
|
+
// run for every duplicate, including agreeing copies and missing-field fills,
|
|
45
|
+
// so the eventual dialog can name the reports that actually supplied a value.
|
|
46
|
+
export function mergeReportDuplicateFields(survivor, dup, reportName, conflicts) {
|
|
47
|
+
if (!survivor || !dup || survivor === dup) return
|
|
48
|
+
if (survivor.correctedSeverity || dup.correctedSeverity) {
|
|
49
|
+
survivor._correctedByReport ??= {
|
|
50
|
+
[survivor._reportName ?? '']: { severity: effectiveSeverity(survivor), reason: survivor.correctedSeverityReason },
|
|
51
|
+
}
|
|
52
|
+
survivor._correctedByReport[reportName] = { severity: effectiveSeverity(dup), reason: dup.correctedSeverityReason }
|
|
53
|
+
}
|
|
54
|
+
const copies = recordRevalidationCopies(survivor, dup, reportName)
|
|
55
|
+
if (mergeDuplicateFields(survivor, dup) || conflicts.has(survivor.id)) {
|
|
56
|
+
conflicts.set(survivor.id, { finding: survivor, copies })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// What the confidence range, the Confirmed outcome and a workspace's basic
|
|
2
|
+
// App view mean for a set of rows. Pure: the view passes its current lens
|
|
3
|
+
// (filters.js), and the managed server classifies team workspaces with it.
|
|
4
|
+
import { displayedSeverity, revalidateKindOf } from '@preventive/report'
|
|
5
|
+
|
|
6
|
+
// `tabKey` identifies an individual tab (= finding) across the view.
|
|
7
|
+
export function tabKey(f) { return f.id ?? String(f._id) }
|
|
8
|
+
|
|
9
|
+
// Does this row carry a judgement the pass made ABOUT a finding —
|
|
10
|
+
// any stamp but `revalidation`, which names the pass's own row and
|
|
11
|
+
// judges nothing. Raw for the same reason as the two above: it gates
|
|
12
|
+
// the switch, and a gate that stopped seeing the stamps the moment
|
|
13
|
+
// the layer came off would take the way back with it.
|
|
14
|
+
export function hasRevalidateStamp(f) {
|
|
15
|
+
const kind = revalidateKindOf(f)
|
|
16
|
+
return kind !== '' && kind !== 'revalidation'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// What the toolbar dropdown offers, in the order it lists them —
|
|
20
|
+
// answers to "did the pass leave this standing", running from yes to
|
|
21
|
+
// no. Fewer options than the field has values:
|
|
22
|
+
//
|
|
23
|
+
// * the `revalidation` row rides CONFIRMED — it is the pass itself,
|
|
24
|
+
// re-examining a finding it did not knock down, which is the same
|
|
25
|
+
// answer to that question;
|
|
26
|
+
// * `partial` rides it too. A partial confirmation is a yes to
|
|
27
|
+
// "does this still stand" — the pass narrowed the finding rather
|
|
28
|
+
// than knocking it down — and an option of its own would slice
|
|
29
|
+
// the standing findings in two for a distinction the reader wants
|
|
30
|
+
// the STAMP for, not a filter. It keeps its own stamp on the card.
|
|
31
|
+
// * `unknown` gets no option — a pass that couldn't tell hasn't
|
|
32
|
+
// answered it at all, so there is nothing to filter to. Those rows
|
|
33
|
+
// stay visible with no filter on, like every other row.
|
|
34
|
+
export const REVALIDATE_FILTERS = [
|
|
35
|
+
{ value: 'confirmed', label: 'Confirmed', kinds: ['confirmed', 'partial', 'revalidation'] },
|
|
36
|
+
{ value: 'unreachable', label: 'Unreachable', kinds: ['unreachable'] },
|
|
37
|
+
{ value: 'refuted', label: 'Refuted', kinds: ['refuted'] },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
// The kinds one dropdown value covers, or null when the value names no
|
|
41
|
+
// option — filters.js reads that as "no filter" rather than hiding
|
|
42
|
+
// every finding behind a value it can't interpret.
|
|
43
|
+
export function revalidateFilterKinds(value) {
|
|
44
|
+
return REVALIDATE_FILTERS.find((o) => o.value === value)?.kinds ?? null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// How finely Confirmed is drawn, once it has taken the partial rows
|
|
48
|
+
// in. The toolbar cycles a chip through these inside the Confirmed row
|
|
49
|
+
// (revalidate-filter.js), because "did this survive" and "how
|
|
50
|
+
// completely" are one question asked twice, not two filters — and the
|
|
51
|
+
// second only has an answer once the first is Confirmed.
|
|
52
|
+
export const PARTIAL_MODES = ['', 'exclude', 'only']
|
|
53
|
+
|
|
54
|
+
// The kinds an outcome selection ACTUALLY matches, with that switch
|
|
55
|
+
// applied. All three are the same shape as every other filter here —
|
|
56
|
+
// a list of kinds, matched existentially over the group — so the chip
|
|
57
|
+
// narrows what Confirmed reaches rather than subtracting from it:
|
|
58
|
+
//
|
|
59
|
+
// '' everything the pass left standing: the row that IS the
|
|
60
|
+
// pass, the full confirmations, the partial ones;
|
|
61
|
+
// 'exclude' the full confirmations only. NOT "everything but the
|
|
62
|
+
// partials": a group is shown for carrying a `confirmed`
|
|
63
|
+
// row, not for lacking a `partial` one, so the pass row
|
|
64
|
+
// on its own no longer stands in for a verdict;
|
|
65
|
+
// 'only' the partial ones.
|
|
66
|
+
//
|
|
67
|
+
// It bites only on an option that took the partial rows in —
|
|
68
|
+
// Confirmed — so a mode left set from an earlier selection can't
|
|
69
|
+
// silently narrow Refuted or Unreachable, and callers can pass it
|
|
70
|
+
// through without checking which option is up.
|
|
71
|
+
export function activeRevalidateKinds(value, partialMode) {
|
|
72
|
+
const kinds = revalidateFilterKinds(value)
|
|
73
|
+
if (!kinds?.includes('partial')) return kinds
|
|
74
|
+
if (partialMode === 'only') return ['partial']
|
|
75
|
+
if (partialMode === 'exclude') return ['confirmed']
|
|
76
|
+
return kinds
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A finding's place on the 0—10 confidence scale, or undefined when it
|
|
80
|
+
// has none. The one reader every confidence question goes through:
|
|
81
|
+
// what the range matches, what the auto-tune counts, and whether the
|
|
82
|
+
// control is offered at all (render.js hasAnyConfidence).
|
|
83
|
+
//
|
|
84
|
+
// Three ways to have a place:
|
|
85
|
+
//
|
|
86
|
+
// * a `confidence` the analyzer scored it with;
|
|
87
|
+
// * `critical: true` — the boolean, NOT `severity: 'critical'` —
|
|
88
|
+
// which stands in for a top score;
|
|
89
|
+
// * coming from another producer at all. An import carries no
|
|
90
|
+
// confidence because its producer doesn't emit one (Claude
|
|
91
|
+
// Security's parser reads none), not because anyone was unsure —
|
|
92
|
+
// there is no doubt here for a floor to act on. Reading it as 10
|
|
93
|
+
// keeps it on screen under any floor and out only under a cap
|
|
94
|
+
// that excludes the top: `8—10` and `2—10` show it, `0—5` and
|
|
95
|
+
// `7—9` don't. It also stops one imported report from taking the
|
|
96
|
+
// range away from a workspace: an unscored finding DISABLES the
|
|
97
|
+
// control for everyone (render.js), which left the range filtering
|
|
98
|
+
// nothing and every low-confidence row on screen.
|
|
99
|
+
//
|
|
100
|
+
// A row the pass knocked down reads as 0 instead, where that matters
|
|
101
|
+
// (voidsConfidence) — applied by the callers, since whether a finding
|
|
102
|
+
// has a place at all is about the finding, not about the pass.
|
|
103
|
+
export function confidenceOnScale(f) {
|
|
104
|
+
if (f.confidence !== undefined) return f.confidence
|
|
105
|
+
if (f.critical === true || f._source) return 10
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Does this finding put itself on the scale, rather than ride the
|
|
110
|
+
// stand-in confidenceOnScale hands a row that carries no score? A
|
|
111
|
+
// number its producer wrote, or the `critical: true` that stands in
|
|
112
|
+
// for one.
|
|
113
|
+
const scoresItself = (f) => f.confidence !== undefined || f.critical === true
|
|
114
|
+
|
|
115
|
+
// Is the confidence range a live control over these rows — offered,
|
|
116
|
+
// and actually filtering? Two conditions, which the toolbar reads as
|
|
117
|
+
// one (render.js hasAnyConfidence):
|
|
118
|
+
//
|
|
119
|
+
// * every row has a place on the scale, or the first lift off 0
|
|
120
|
+
// would silently drop the ones that don't. A single analyzer
|
|
121
|
+
// finding with no confidence and no `critical: true` disables the
|
|
122
|
+
// control for the whole set;
|
|
123
|
+
// * something on screen puts ITSELF on that scale. A row riding the
|
|
124
|
+
// stand-in doesn't: a set of nothing but unscored imports is all
|
|
125
|
+
// 10s by definition, and a range over one value says nothing.
|
|
126
|
+
// There the control isn't disabled, it isn't offered at all — the
|
|
127
|
+
// toolbar drops the whole block when the outcome dropdown beside
|
|
128
|
+
// it has nothing to offer either.
|
|
129
|
+
//
|
|
130
|
+
// What answers this is the SCORE, not who wrote it. Asked as "not
|
|
131
|
+
// an import" it gave the same answer for every producer that emits
|
|
132
|
+
// no confidence — and the wrong one for DeepSec, which rates every
|
|
133
|
+
// finding it reports and whose words this app places on the scale
|
|
134
|
+
// itself (@preventive/report/src/parse-deepsec.js). A workspace of nothing but
|
|
135
|
+
// DeepSec reports is a real range over real numbers, and asking
|
|
136
|
+
// for a non-import took the slider, the confidence sort and the
|
|
137
|
+
// opening floor away from exactly the load whose producer had
|
|
138
|
+
// scored every row in it.
|
|
139
|
+
//
|
|
140
|
+
// Both halves are asked of the rows ON SCREEN by every caller, since
|
|
141
|
+
// this is about a control in front of a reader.
|
|
142
|
+
export function rangeApplies(groups) {
|
|
143
|
+
return groups.every((g) => g.every((f) => confidenceOnScale(f) !== undefined))
|
|
144
|
+
&& groups.some((g) => g.some(scoresItself))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Does the confidence floor leave this group on screen? The rule
|
|
148
|
+
// matchesFilters applies (filters.js), with the upper bound at 10 so only the
|
|
149
|
+
// floor bites, hoisted to the group because that is the unit the list
|
|
150
|
+
// shows: a group is on screen when ANY of its rows clears the floor —
|
|
151
|
+
// an unscored row only at floor 0 unless it is flagged `critical`, and
|
|
152
|
+
// a row the pass knocked down reading as 0 whatever number it carries.
|
|
153
|
+
function showsAtConfidence(g, min, kindOf) {
|
|
154
|
+
return g.some((f) => {
|
|
155
|
+
const conf = ['refuted', 'unreachable'].includes(kindOf(f)) ? 0 : confidenceOnScale(f)
|
|
156
|
+
return conf === undefined ? min === 0 : conf >= min
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The confidence floor a freshly-loaded set should OPEN on, tuned so
|
|
161
|
+
// the initial view fits ~25 groups. Step up 6 → 7 → 8 until the
|
|
162
|
+
// visible count is within budget; cap at 8 (the old static default).
|
|
163
|
+
// Nothing carrying a confidence at all means no floor — without that
|
|
164
|
+
// guard countAtMin(6) = 0 ≤ 25 lands it at 6, which then excludes
|
|
165
|
+
// every finding; 0 lets the filter no-op instead, and the toolbar
|
|
166
|
+
// hides the control anyway (see toolbarHtml in render.js).
|
|
167
|
+
//
|
|
168
|
+
// After picking the base, walk DOWN while each lower step surfaces no
|
|
169
|
+
// new groups — i.e. there's a "gap" in the confidence distribution
|
|
170
|
+
// below the chosen floor. Lowering for free puts the slider at the
|
|
171
|
+
// natural break: e.g. picked 8, nothing at 7 or 6 but some at 5 →
|
|
172
|
+
// settle at 6 (the lowest step revealing nothing new). Down to 0.
|
|
173
|
+
//
|
|
174
|
+
// Pure in its argument, and paired with defaultRevalidateFilter below:
|
|
175
|
+
// together they are "what does this set open on", asked by ingest.js
|
|
176
|
+
// on a first load and again by the App switch (events.js), which
|
|
177
|
+
// reshapes the set and so has to ask again rather than keep an answer
|
|
178
|
+
// that was about a different one.
|
|
179
|
+
export function defaultConfidenceFloor(groups, tabs) {
|
|
180
|
+
// App mode folds the source tabs a revalidation pass already answered
|
|
181
|
+
// under its App tab. Those hidden copies must not lower the opening floor
|
|
182
|
+
// for a workspace that otherwise has the same visible rows as its App
|
|
183
|
+
// report. Keep source-only rows intact; they are real rows in the App view.
|
|
184
|
+
const visible = groups.map(tabs).filter((g) => g.length > 0)
|
|
185
|
+
if (!visible.some((g) => g.some((f) => confidenceOnScale(f) !== undefined))) return 0
|
|
186
|
+
const countAtMin = (min) => visible.reduce((n, g) =>
|
|
187
|
+
n + (g.some((f) => (confidenceOnScale(f) ?? -1) >= min) ? 1 : 0), 0)
|
|
188
|
+
let base
|
|
189
|
+
if (countAtMin(6) <= 25) base = 6
|
|
190
|
+
else if (countAtMin(7) <= 25) base = 7
|
|
191
|
+
else base = 8
|
|
192
|
+
while (base > 0 && countAtMin(base - 1) === countAtMin(base)) base--
|
|
193
|
+
return base
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The floor the default view will REALLY apply. The range is a
|
|
197
|
+
// whole-set control: one finding on screen with no confidence and no
|
|
198
|
+
// `critical: true` and render.js disables it and resets the bounds to
|
|
199
|
+
// 0—10 (hasAnyConfidence there), so the auto-tuned floor never bites
|
|
200
|
+
// and every row is on screen — an unscored finding is not hidden by a
|
|
201
|
+
// filter that isn't running. Measuring the comparison below against a
|
|
202
|
+
// floor the view is about to throw away would be measuring a screen
|
|
203
|
+
// nobody sees.
|
|
204
|
+
//
|
|
205
|
+
// `critical: true` rides the 10 bucket in place of a score
|
|
206
|
+
// (matchesFilters), so it doesn't block the range any more than a
|
|
207
|
+
// number does.
|
|
208
|
+
//
|
|
209
|
+
// render.js asks this of the on-screen bucket and we ask it of the
|
|
210
|
+
// whole set; at open time, before anything is triaged away, they are
|
|
211
|
+
// the same groups.
|
|
212
|
+
function effectiveFloor(groups, confMin) {
|
|
213
|
+
return rangeApplies(groups) ? confMin : 0
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Both the opening default and the dropdown lock use the same coverage:
|
|
217
|
+
// findings represented by Confirmed rows, plus findings explicitly ruled out.
|
|
218
|
+
// Read the original groups before `tabs` folds away their source members.
|
|
219
|
+
export function confirmedCoverage(groups, tabs, kindOf) {
|
|
220
|
+
// A workspace may carry an un-stamped source copy from report A while
|
|
221
|
+
// report B explicitly ruled out the same id. The ruled-out copy is
|
|
222
|
+
// removed before workspace rows are merged, so it cannot vouch for its
|
|
223
|
+
// source copy during the coverage check below. Remember those ids before
|
|
224
|
+
// that projection disappears: Confirmed may hide the source copy too,
|
|
225
|
+
// even when the App finding that caused the ruling has no
|
|
226
|
+
// `revalidateInputs` list of its own.
|
|
227
|
+
const covered = new Set(groups.ruledOutIds ?? [])
|
|
228
|
+
for (const raw of groups) {
|
|
229
|
+
for (const f of raw) if (['refuted', 'unreachable'].includes(kindOf(f))) covered.add(tabKey(f))
|
|
230
|
+
}
|
|
231
|
+
for (const raw of groups) {
|
|
232
|
+
const g = tabs(raw)
|
|
233
|
+
if (g.length === 0) continue
|
|
234
|
+
if (!g.some((f) => activeRevalidateKinds('confirmed', '').includes(
|
|
235
|
+
kindOf(f) || (f._source && !f._sourcePass ? 'revalidation' : '')))) continue
|
|
236
|
+
// A folded source copy is not a cost of Confirmed, but its id is still
|
|
237
|
+
// represented by the App row. Keep all raw members in the coverage set
|
|
238
|
+
// so a duplicate source-only row from another report is recognized as
|
|
239
|
+
// the same issue rather than making Confirmed look lossy.
|
|
240
|
+
for (const f of raw) {
|
|
241
|
+
covered.add(tabKey(f))
|
|
242
|
+
// App reports can carry the source findings they represent only as
|
|
243
|
+
// `revalidateInputs`, rather than as members of the same physical row.
|
|
244
|
+
// Those inputs are still present in the App row's visible result, so a
|
|
245
|
+
// duplicate source-only row from another report must not keep the
|
|
246
|
+
// workspace on Confidence.
|
|
247
|
+
if (f.isApp && Array.isArray(f.revalidateInputs)) {
|
|
248
|
+
for (const id of f.revalidateInputs) if (id) covered.add(id)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return covered
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function confirmedIsDefault(visible, confMin, covered, kindOf) {
|
|
256
|
+
const floor = effectiveFloor(visible, confMin)
|
|
257
|
+
const shown = visible.filter((g) => showsAtConfidence(g, floor, kindOf))
|
|
258
|
+
if (shown.length === 0) return false
|
|
259
|
+
const kinds = new Set(activeRevalidateKinds('confirmed', ''))
|
|
260
|
+
if (!visible.some((g) => g.some((f) => kinds.has(kindOf(f))))) return false
|
|
261
|
+
return shown.every((g) => g.every((f) => covered.has(tabKey(f))))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The same coverage test evaluates a workspace's basic App view without the
|
|
265
|
+
// reader's current lens: the view and the managed server pass the lens to use.
|
|
266
|
+
export function canLockConfirmed(groups, { tabs, kindOf, severityMode }) {
|
|
267
|
+
const visible = groups.map(tabs).filter((g) => g.length > 0)
|
|
268
|
+
const covered = confirmedCoverage(groups, tabs, kindOf)
|
|
269
|
+
if (!confirmedIsDefault(visible, defaultConfidenceFloor(groups, tabs), covered, kindOf)) return false
|
|
270
|
+
const floor = effectiveFloor(visible, 6)
|
|
271
|
+
return visible.every((g) => !showsAtConfidence(g, floor, kindOf)
|
|
272
|
+
|| !g.some((f) => displayedSeverity(f, severityMode) !== 'low')
|
|
273
|
+
|| g.every((f) => covered.has(tabKey(f))))
|
|
274
|
+
}
|
package/common/github-pr.ts
CHANGED
package/common/gzip.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Gzip helpers built on the platform CompressionStream /
|
|
2
|
+
// DecompressionStream. Centralised so the half-dozen call sites
|
|
3
|
+
// (storage, triage / workspace export + import, sync-crypto) share
|
|
4
|
+
// one implementation of the Blob → stream → Response plumbing instead
|
|
5
|
+
// of hand-copying it. Bytes in / bytes out is the primitive; the text
|
|
6
|
+
// helpers run the bytes through `common/utf8.js` so the same
|
|
7
|
+
// fatal-on-invalid / BOM-preserving decode rules apply everywhere.
|
|
8
|
+
//
|
|
9
|
+
// `deflate` (raw, no gzip framing) is intentionally NOT covered here —
|
|
10
|
+
// `triage.js` uses it for its own at-rest blob and must stay distinct
|
|
11
|
+
// from the gzip-magic sniffing the bundles / import paths rely on.
|
|
12
|
+
|
|
13
|
+
import { decodeUtf8, encodeUtf8 } from './utf8.js'
|
|
14
|
+
|
|
15
|
+
export async function gzipBytes(bytes) {
|
|
16
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'))
|
|
17
|
+
return new Uint8Array(await new Response(stream).arrayBuffer())
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// `maxBytes` (optional) caps the DECOMPRESSED size: gzip expands up to
|
|
21
|
+
// ~1032:1, so a small hostile payload can balloon to GiBs and OOM the
|
|
22
|
+
// process before any content validation runs. Callers decompressing
|
|
23
|
+
// peer-controlled bytes (sync-crypto's inbound changesets) pass a cap;
|
|
24
|
+
// the read then aborts with a RangeError the moment the budget is crossed,
|
|
25
|
+
// instead of materialising the full expansion. Uncapped callers keep
|
|
26
|
+
// the one-shot Response read.
|
|
27
|
+
export async function gunzipBytes(bytes, { maxBytes } = {}) {
|
|
28
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))
|
|
29
|
+
if (maxBytes == null) return new Uint8Array(await new Response(stream).arrayBuffer())
|
|
30
|
+
const reader = stream.getReader()
|
|
31
|
+
const chunks = []
|
|
32
|
+
let total = 0
|
|
33
|
+
for (;;) {
|
|
34
|
+
const { value, done } = await reader.read()
|
|
35
|
+
if (done) break
|
|
36
|
+
total += value.length
|
|
37
|
+
if (total > maxBytes) {
|
|
38
|
+
// Cancel so the DecompressionStream stops inflating the rest.
|
|
39
|
+
try { await reader.cancel() } catch {}
|
|
40
|
+
throw new RangeError(`gunzipBytes: decompressed size exceeds ${maxBytes}-byte cap`)
|
|
41
|
+
}
|
|
42
|
+
chunks.push(value)
|
|
43
|
+
}
|
|
44
|
+
const out = new Uint8Array(total)
|
|
45
|
+
let offset = 0
|
|
46
|
+
for (const chunk of chunks) {
|
|
47
|
+
out.set(chunk, offset)
|
|
48
|
+
offset += chunk.length
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function gzipText(text) {
|
|
54
|
+
return await gzipBytes(encodeUtf8(text))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function gunzipToText(bytes) {
|
|
58
|
+
return decodeUtf8(await gunzipBytes(bytes))
|
|
59
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Type declarations for `common/line-diff.js`, for `.ts` callers.
|
|
2
|
+
|
|
3
|
+
// Lines `a[a0..a1)` replaced by `b[b0..b1)`.
|
|
4
|
+
export interface ChangeBlock { a0: number; a1: number; b0: number; b1: number }
|
|
5
|
+
|
|
6
|
+
export function textLines(text: string): string[]
|
|
7
|
+
export function changeBlocks(before: string, after: string, ignoreWhitespace?: boolean): ChangeBlock[]
|
|
8
|
+
export function blocksHold(before: string, after: string, blocks: unknown, ignoreWhitespace?: boolean): boolean
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The change blocks between two texts' lines, found by @preventive/diff: what
|
|
2
|
+
// the viewer's Compare diffs (ui/view/bundle-compare-code-model.js); and the
|
|
3
|
+
// server's check, apart from @preventive/compare's own, that its diff with
|
|
4
|
+
// renamed names hidden takes the old side to the new (server-managed/rename-diff.ts).
|
|
5
|
+
//
|
|
6
|
+
// Lines are numbered the way the diff numbers them: from zero, a final
|
|
7
|
+
// newline ending the last line rather than starting an empty one.
|
|
8
|
+
import { diff, parseDiff } from '@preventive/diff'
|
|
9
|
+
|
|
10
|
+
export function textLines(text) {
|
|
11
|
+
if (text === '') return []
|
|
12
|
+
const lines = text.split('\n')
|
|
13
|
+
if (lines.at(-1) === '') lines.pop()
|
|
14
|
+
return lines
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Whether a text's last line ends in a newline. One added or dropped there is
|
|
18
|
+
// a change of that line, as diff has it (the viewer marks it on that line's
|
|
19
|
+
// row), but for diff -b, which ignores it as it does other trailing blanks.
|
|
20
|
+
const endsLine = text => text === '' || text.endsWith('\n')
|
|
21
|
+
|
|
22
|
+
// A line as diff -b reads it (@preventive/diff's): a run of the C locale's
|
|
23
|
+
// blanks as one space, and those that end it as none.
|
|
24
|
+
const spacing = line => line.replace(/[ \t\n\v\f\r]+$/u, '').replaceAll(/[ \t\n\v\f\r]+/gu, ' ')
|
|
25
|
+
|
|
26
|
+
// Each `a[a0..a1)` replaced by `b[b0..b1)`. `ignoreWhitespace` is diff -b:
|
|
27
|
+
// lines differing only in how much whitespace they have pair up as unchanged,
|
|
28
|
+
// whitespace added or removed between other characters still a change.
|
|
29
|
+
export function changeBlocks(before, after, ignoreWhitespace = false) {
|
|
30
|
+
const text = diff(before, after, { format: 'unified', context: 0, whitespace: ignoreWhitespace ? 'change' : 'none' })
|
|
31
|
+
return text ? (parseDiff(text)[0]?.blocks ?? []).map(({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 })) : []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Whether `blocks` take `before`'s lines to `after`'s: each in order and apart
|
|
35
|
+
// from the last, within both texts, and every line between them alike on both
|
|
36
|
+
// sides (alike but for how much whitespace they have where `ignoreWhitespace`,
|
|
37
|
+
// as diff -b has them), the last line in a block where a newline was added or
|
|
38
|
+
// dropped after it, so that a diff drawn from them hides no change. A check on
|
|
39
|
+
// what changeBlocks found, or on one found apart from the texts drawn.
|
|
40
|
+
export function blocksHold(before, after, blocks, ignoreWhitespace = false) {
|
|
41
|
+
if (!Array.isArray(blocks)) return false
|
|
42
|
+
const a = textLines(before), b = textLines(after)
|
|
43
|
+
const alike = ignoreWhitespace ? (x, y) => spacing(x) === spacing(y) : (x, y) => x === y
|
|
44
|
+
let i = 0, j = 0
|
|
45
|
+
for (const block of [...blocks, { a0: a.length, a1: a.length, b0: b.length, b1: b.length, last: true }]) {
|
|
46
|
+
const { a0, a1, b0, b1 } = block ?? {}
|
|
47
|
+
if (![a0, a1, b0, b1].every(Number.isInteger) || a0 < i || b0 < j || a1 < a0 || b1 < b0 || a1 > a.length || b1 > b.length) return false
|
|
48
|
+
// A block changes a line, and as many lines are alike before it on each side.
|
|
49
|
+
if ((!block.last && a1 === a0 && b1 === b0) || a0 - i !== b0 - j) return false
|
|
50
|
+
if (block.last && a0 > i && !ignoreWhitespace && endsLine(before) !== endsLine(after)) return false
|
|
51
|
+
for (; i < a0; i++, j++) if (!alike(a[i], b[j])) return false
|
|
52
|
+
i = a1
|
|
53
|
+
j = b1
|
|
54
|
+
}
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Explicit links combine already-resolved workspace App rows. This is a view
|
|
2
|
+
// grouping, not report deduplication: each report's revalidation answer has
|
|
3
|
+
// already been checked in its own row, and links never create report conflicts.
|
|
4
|
+
//
|
|
5
|
+
// `visibleTabs` decides which tabs may BRIDGE two rows; `retainedTabs` (the
|
|
6
|
+
// same projection unless the caller says otherwise) decides which tabs the
|
|
7
|
+
// combined row keeps. They part when a display choice shows a row more of
|
|
8
|
+
// itself without meaning to re-draw the workspace's row boundaries.
|
|
9
|
+
export function mergeLinkedWorkspaceGroups(groups, duplicatesOf, visibleTabs, retainedTabs = visibleTabs) {
|
|
10
|
+
const parent = groups.map((_, i) => i)
|
|
11
|
+
const find = (i) => {
|
|
12
|
+
while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i] }
|
|
13
|
+
return i
|
|
14
|
+
}
|
|
15
|
+
const tabs = groups.map(visibleTabs)
|
|
16
|
+
const byId = new Map()
|
|
17
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
18
|
+
for (const f of tabs[i]) {
|
|
19
|
+
if (!f.id) continue
|
|
20
|
+
if (!byId.has(f.id)) byId.set(f.id, [])
|
|
21
|
+
byId.get(f.id).push(i)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const [id, rows] of byId) {
|
|
25
|
+
for (const other of duplicatesOf(id)) {
|
|
26
|
+
if (other === id) continue
|
|
27
|
+
for (const a of rows) {
|
|
28
|
+
for (const b of byId.get(other) ?? []) {
|
|
29
|
+
const left = find(a), right = find(b)
|
|
30
|
+
if (left !== right) parent[right] = left
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const components = new Map()
|
|
36
|
+
for (let i = 0; i < groups.length; i++) {
|
|
37
|
+
const root = find(i)
|
|
38
|
+
if (!components.has(root)) components.set(root, [])
|
|
39
|
+
components.get(root).push(i)
|
|
40
|
+
}
|
|
41
|
+
if (components.size === groups.length) return groups
|
|
42
|
+
const key = (f) => f.id ?? String(f._id)
|
|
43
|
+
const merged = [...components.values()].map((indices) => {
|
|
44
|
+
if (indices.length === 1) return groups[indices[0]]
|
|
45
|
+
// Retain the tabs visible in each original row. An imported App finding
|
|
46
|
+
// or an unjudged source row must not disappear when linked to a pass row.
|
|
47
|
+
const visible = new Map()
|
|
48
|
+
for (const i of indices) for (const f of retainedTabs(groups[i])) if (!visible.has(key(f))) visible.set(key(f), f)
|
|
49
|
+
const members = new Map()
|
|
50
|
+
for (const i of indices) {
|
|
51
|
+
for (const f of groups[i]) {
|
|
52
|
+
if (!members.has(key(f))) members.set(key(f), visible.get(key(f)) ?? f)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const group = [...members.values()]
|
|
56
|
+
const first = groups[indices[0]]
|
|
57
|
+
group.workspaceKey = first.workspaceKey ?? key(first[0])
|
|
58
|
+
group.linkedTabs = [...visible.values()]
|
|
59
|
+
return group
|
|
60
|
+
})
|
|
61
|
+
Object.defineProperty(merged, 'ruledOutIds', { value: groups.ruledOutIds, configurable: true })
|
|
62
|
+
return merged
|
|
63
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const MAX_TEAM_NPM_SCOPES: number
|
|
2
|
+
export function isNpmPackageName(value: unknown): value is string
|
|
3
|
+
export function isNpmPackageSpec(value: unknown): value is string
|
|
4
|
+
export function npmPackageScope(name: string): string | null
|
|
5
|
+
export function normalizeNpmScope(value: unknown): string | null
|
|
@@ -0,0 +1,34 @@
|
|
|
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.
|
|
3
|
+
|
|
4
|
+
// Upstream's rule (@preventive/upstream src/args.js): a scope, then a name
|
|
5
|
+
// starting with none of `.`, `_` and `-`, 214 characters in all.
|
|
6
|
+
const PACKAGE_NAME = /^(?=.{1,214}$)(?:@[\w.-]+\/[\w-]|[\dA-Za-z])[\w.-]*$/u
|
|
7
|
+
// An exact version or a dist-tag, as the registry resolves `/<name>/<spec>`.
|
|
8
|
+
const PACKAGE_SPEC = /^(?=.{1,256}$)[\dA-Za-z][\w.+-]*$/u
|
|
9
|
+
// npm's rule for a scope: lowercase, and not starting with `.` or `_`.
|
|
10
|
+
const SCOPE = /^@[\da-z~-][\w.~-]*$/u
|
|
11
|
+
export const MAX_TEAM_NPM_SCOPES = 100
|
|
12
|
+
|
|
13
|
+
export function isNpmPackageName(value) {
|
|
14
|
+
return typeof value === 'string' && PACKAGE_NAME.test(value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isNpmPackageSpec(value) {
|
|
18
|
+
return typeof value === 'string' && PACKAGE_SPEC.test(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// `@scope` of a scoped name, lowercased as scopes are compared; null for an
|
|
22
|
+
// unscoped one.
|
|
23
|
+
export function npmPackageScope(name) {
|
|
24
|
+
return name.startsWith('@') && name.includes('/') ? name.slice(0, name.indexOf('/')).toLowerCase() : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A scope as typed: `scope` or `@scope`, either case, surrounding space
|
|
28
|
+
// ignored. Null when it is not one npm could publish under.
|
|
29
|
+
export function normalizeNpmScope(value) {
|
|
30
|
+
if (typeof value !== 'string') return null
|
|
31
|
+
const trimmed = value.trim().toLowerCase()
|
|
32
|
+
const scope = trimmed.startsWith('@') ? trimmed : `@${trimmed}`
|
|
33
|
+
return scope.length <= 214 && SCOPE.test(scope) ? scope : null
|
|
34
|
+
}
|