@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.40
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/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +2 -0
- package/client/dependency-paths.js +24 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +112 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -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 +14 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +310 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- 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/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- 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/comments.ts +34 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +254 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- 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/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -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-4F6JSF57.js +3 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-AQ75QITE.js +1 -0
- package/out/chunk-B2RQY6OP.js +2 -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-DIJJNTRH.js +10 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-IISEF2BZ.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -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-MYDDTA7W.js +225 -0
- package/out/chunk-NRPC74AM.js +1 -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-RSHMVYNS.js +1 -0
- package/out/chunk-TGANXVTA.js +1 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UN5ZSYLE.js +1 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UXNUAIIC.js +33 -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-VQJCXGIY.js +7 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-WZDTZE64.js +1 -0
- package/out/chunk-XYDGU6BM.js +51 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZBBOBQ43.js +1 -0
- package/out/chunk-ZMQQCGF6.js +2 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +37 -0
- package/out/client-sync.js +10 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +6 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +365 -39
- package/out/view.css +1 -1
- package/out/view.js +83 -117
- package/package.json +297 -59
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +358 -0
- package/risk/files/script.js +114 -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 +228 -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 +34 -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-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +116 -18
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +27 -26
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +245 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +111 -0
- package/server-managed/bundle-build.ts +198 -0
- package/server-managed/bundle-cache.ts +199 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +120 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +158 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1725 -0
- package/server-managed/db-neon.ts +367 -0
- package/server-managed/db-schema.ts +227 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +132 -0
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +391 -0
- package/server-managed/github-errors.ts +68 -0
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +235 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +50 -0
- package/server-managed/http.ts +3472 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +187 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +75 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -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/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +74 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +220 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +153 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-risk.ts +418 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +92 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +76 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +279 -0
- package/server-managed/storage-maintenance.ts +256 -0
- package/server-managed/storage-payload.ts +103 -0
- package/server-managed/storage-stores.ts +85 -0
- package/server-managed/storage.ts +78 -0
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/team-reports.ts +228 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +86 -0
- package/server-managed/upstream-cache.ts +215 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +166 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The "links" file — a small JSON document that says which findings
|
|
2
|
+
// are the SAME finding, reported twice:
|
|
3
|
+
//
|
|
4
|
+
// [["a", "b"], ["c", "d", "e"]]
|
|
5
|
+
// Entries may also be objects such as {"id":"a"}, for existing exports.
|
|
6
|
+
//
|
|
7
|
+
// One inner array per link. Every finding named in it is a duplicate
|
|
8
|
+
// of every other one in that array, and the file says nothing else:
|
|
9
|
+
// no severity, no title, no file, no prose. That is the whole format.
|
|
10
|
+
//
|
|
11
|
+
// It is NOT a report, and this module deliberately sits outside
|
|
12
|
+
// `@preventive/report` because of it. A report carries findings; a links file
|
|
13
|
+
// carries only ids of findings that must already live in reports the
|
|
14
|
+
// reader holds. So it never reaches `ingestReport` / `state.reports`,
|
|
15
|
+
// it has its own view (`ui/view/render-links.js`) that points at the
|
|
16
|
+
// findings. It also supplies the "Duplicates:" row on finding cards
|
|
17
|
+
// and groups explicitly linked rows in workspace App view.
|
|
18
|
+
//
|
|
19
|
+
// Pure — no storage, no DOM, no app state. `linked-findings-index.js`
|
|
20
|
+
// beside this file is the OPFS-wide store built on top; everything
|
|
21
|
+
// here is text in, data out, so the recognition rules are testable on
|
|
22
|
+
// their own.
|
|
23
|
+
|
|
24
|
+
import { isLinkableFindingId } from './finding-link.js'
|
|
25
|
+
|
|
26
|
+
// The `source` marker a links file is filed under — the same slot a
|
|
27
|
+
// report's producer ('deepsec', 'piolium', …) occupies in the counts
|
|
28
|
+
// cache, so `getKind(name)` answers "what kind of file is this" for
|
|
29
|
+
// links and reports alike and the sidebar can bucket both from one
|
|
30
|
+
// lookup. No report format can collide with it: this value comes from
|
|
31
|
+
// here, never from a document's own content.
|
|
32
|
+
export const LINKS_KIND = 'links'
|
|
33
|
+
|
|
34
|
+
// Read a string ID or an existing object entry — `{"id": "…"}`. Extra fields are allowed and ignored: an exporter that
|
|
35
|
+
// writes the title or the report alongside the id is writing a
|
|
36
|
+
// superset of this format, not a different one.
|
|
37
|
+
function entryId(entry) {
|
|
38
|
+
if (typeof entry === 'string') return entry.length > 0 ? entry : null
|
|
39
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null
|
|
40
|
+
const { id } = entry
|
|
41
|
+
return typeof id === 'string' && id.length > 0 ? id : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Recognise `content` as a links file and normalise it.
|
|
45
|
+
//
|
|
46
|
+
// Returns `{ groups, skipped }` — `groups` is one `string[]` of finding
|
|
47
|
+
// ids per link, and `skipped` counts ids the app could never follow (a
|
|
48
|
+
// session-local numeric id, a control character, something absurdly
|
|
49
|
+
// long — `isLinkableFindingId` draws the same line the per-finding Link
|
|
50
|
+
// button draws). Returns null when the text isn't this format at all.
|
|
51
|
+
//
|
|
52
|
+
// Recognition is on SHAPE, and it is strict, because this parser runs
|
|
53
|
+
// against every dropped file before the report readers get their turn:
|
|
54
|
+
// a top-level array, holding only arrays, holding string IDs or objects with a
|
|
55
|
+
// string `id`. Nothing else this app reads is a bare JSON array, so
|
|
56
|
+
// nothing else can be mistaken for one — and an empty top-level array
|
|
57
|
+
// is refused rather than claimed, since `[]` is every empty JSON list
|
|
58
|
+
// in the world, not distinctively a links file.
|
|
59
|
+
//
|
|
60
|
+
// Normalisation drops what carries no information: ids repeated inside
|
|
61
|
+
// one link, ids that can't be linked, and then any link left naming
|
|
62
|
+
// fewer than two findings — a "link" to a single finding links nothing.
|
|
63
|
+
// A file whose every link normalises away is still a links file; it
|
|
64
|
+
// just has no links, which its view says plainly.
|
|
65
|
+
export function parseLinkedFindings(content) {
|
|
66
|
+
let data
|
|
67
|
+
try { data = JSON.parse(content) } catch { return null }
|
|
68
|
+
if (!Array.isArray(data) || data.length === 0) return null
|
|
69
|
+
const groups = []
|
|
70
|
+
let skipped = 0
|
|
71
|
+
for (const raw of data) {
|
|
72
|
+
if (!Array.isArray(raw)) return null
|
|
73
|
+
const ids = new Set()
|
|
74
|
+
for (const entry of raw) {
|
|
75
|
+
const id = entryId(entry)
|
|
76
|
+
if (id === null) return null
|
|
77
|
+
if (isLinkableFindingId(id)) ids.add(id)
|
|
78
|
+
else skipped++
|
|
79
|
+
}
|
|
80
|
+
if (ids.size > 1) groups.push([...ids])
|
|
81
|
+
}
|
|
82
|
+
return { groups, skipped }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// How many findings the links in `groups` name, counted once each — a
|
|
86
|
+
// finding named by two links is one linked finding. What the sidebar
|
|
87
|
+
// badge and the view's header report alongside the link count.
|
|
88
|
+
export function countLinkedIds(groups) {
|
|
89
|
+
const ids = new Set()
|
|
90
|
+
for (const group of groups) for (const id of group) ids.add(id)
|
|
91
|
+
return ids.size
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fold links into `index` (a `Map<id, Set<id>>`): every id in a link
|
|
95
|
+
// gains every OTHER id in it as a duplicate. Called once per links
|
|
96
|
+
// file, so an id linked by two files ends up with the union of what
|
|
97
|
+
// both said about it.
|
|
98
|
+
//
|
|
99
|
+
// Union, NOT transitive closure. If one file links a↔b and another
|
|
100
|
+
// links b↔c, then b has two duplicates and a has one — a and c were
|
|
101
|
+
// never said to be the same finding, and inferring it would put a
|
|
102
|
+
// claim in the reader's card that no file they dropped ever made.
|
|
103
|
+
export function collectDuplicates(groups, index) {
|
|
104
|
+
for (const group of groups) {
|
|
105
|
+
for (const id of group) {
|
|
106
|
+
let set = index.get(id)
|
|
107
|
+
if (!set) index.set(id, set = new Set())
|
|
108
|
+
for (const other of group) if (other !== id) set.add(other)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return index
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function bundleCommitHash(value: unknown): string | null
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export function computeBundleDiff(base: Map<string, unknown>, other: Map<string, unknown>, pkgOf: (path: string) => string, layout?: import('@preventive/compare').Layout): {
|
|
2
|
+
files: { onlyBase: { path: string; basePath?: string }[]; onlyOther: { path: string }[]; changed: { path: string; basePath?: string; renamedFrom?: string }[] }
|
|
3
|
+
}
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// Shared bundle comparison — no Lit, no DOM, no `state`. Given two
|
|
2
|
+
// `Map<path, content>` file maps (the shape `bundleFilesAsMap` returns
|
|
3
|
+
// for either a sourcemap or a stasis bundle: every file the terminal
|
|
4
|
+
// mounts, source or resource, with no directory captures) plus a
|
|
5
|
+
// `pkgOf(path)` bucketing function, it computes a structural diff:
|
|
6
|
+
// which files exist only in one side, which exist in both but changed,
|
|
7
|
+
// per-package size deltas, and the roll-up totals.
|
|
8
|
+
//
|
|
9
|
+
// Kept free of Lit, the DOM and the OPFS parse pipeline on purpose, so
|
|
10
|
+
// `<bundle-compare>` (the Compare slide in the bundles view) and its
|
|
11
|
+
// unit test can both consume the same logic — the test exercises this
|
|
12
|
+
// module directly. Its imports are the file sizer the Overview uses and
|
|
13
|
+
// @preventive/compare's renames and layout, all pure. The component is a
|
|
14
|
+
// thin rendering shell around `computeBundleDiff`.
|
|
15
|
+
//
|
|
16
|
+
// `base` is the currently-open bundle (the tab you're viewing);
|
|
17
|
+
// `other` is the bundle picked to compare against. The result names
|
|
18
|
+
// the two sides `onlyBase` / `onlyOther` rather than added / removed
|
|
19
|
+
// so the UI can label them with each bundle's actual name — "added"
|
|
20
|
+
// is ambiguous without knowing which side is newer.
|
|
21
|
+
|
|
22
|
+
import { compareTrees, treeLayout } from '@preventive/compare'
|
|
23
|
+
import { bundleFileByteLength } from './bundle-sources.js'
|
|
24
|
+
import { resolutionKey } from './bundle-compare-inputs.js'
|
|
25
|
+
|
|
26
|
+
// Resolutions compare independently from file bytes: identical files can be
|
|
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.
|
|
30
|
+
// `renames` (base path → other path, see renamesOf) names base files as
|
|
31
|
+
// the other bundle does: a renamed importer's resolutions pair with its new
|
|
32
|
+
// path's, reported under it, and a target renamed is the same target.
|
|
33
|
+
// `basePaths` and `root` (computeBundleDiff's `files`) name the rest where
|
|
34
|
+
// the bundles' roots differ, a base target shown as the other bundle would
|
|
35
|
+
// lay it out; a path no file has (an importer the bundle didn't capture, a
|
|
36
|
+
// 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) {
|
|
38
|
+
const names = path => {
|
|
39
|
+
const known = renames.get(path) ?? basePaths.get(path)
|
|
40
|
+
return known === undefined ? [...new Set([path, through(root, path) ?? path])] : [known]
|
|
41
|
+
}
|
|
42
|
+
const changed = []
|
|
43
|
+
for (const before of base.values()) {
|
|
44
|
+
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 })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
changed.sort((a, b) => a.key.localeCompare(b.key))
|
|
51
|
+
return { changed, totalChanges: changed.length }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A file's size, by the measure the Overview and Treemap use: text by the
|
|
55
|
+
// UTF-8 it encodes to, a base64 resource by the bytes it decodes to. A
|
|
56
|
+
// file with no size to give (a base64 spelling that does not decode)
|
|
57
|
+
// counts as zero bytes, but is still compared.
|
|
58
|
+
function byteLen(content) {
|
|
59
|
+
return bundleFileByteLength(content) ?? 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Whether two sides hold the same file. Text compares as text; a base64
|
|
63
|
+
// resource arrives as a fresh `{ format: 'base64', data }` on each side,
|
|
64
|
+
// so it compares by its spelling — identity would call every image
|
|
65
|
+
// changed. A file that is text on one side and base64 on the other has
|
|
66
|
+
// changed. An npm package's binary file compares by its digest.
|
|
67
|
+
function sameContent(a, b) {
|
|
68
|
+
return a === b || (a?.format === 'base64' && b?.format === 'base64' && a.data === b.data)
|
|
69
|
+
|| (a?.format === 'digest' && b?.format === 'digest' && a.digest === b.digest)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// What a stasis bundle records of a file beside its bytes: the format it
|
|
73
|
+
// loads it in (a resource is carried as data, the rest run as code), whether
|
|
74
|
+
// it carries an execute bit, and whether the bundle starts from it. A
|
|
75
|
+
// function of the file's path; null for another kind of bundle, which
|
|
76
|
+
// records none of it.
|
|
77
|
+
export function bundleFileMeta(details) {
|
|
78
|
+
if (details?.kind !== 'stasis' || !details.bundle) return null
|
|
79
|
+
const { formats, executable, entries } = details.bundle
|
|
80
|
+
return path => ({ format: formats?.get(path), executable: executable?.has(path) ?? false, entry: entries?.has(path) ?? false })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// How a file's record changed from one stasis bundle to the other, as
|
|
84
|
+
// `{ field, from, to }` (bundleFileMeta's fields): none where either side
|
|
85
|
+
// records nothing. A format left unsaid is one too: stasis runs such a
|
|
86
|
+
// file as code, so a resource whose format is dropped runs.
|
|
87
|
+
function metaChanges(before, after) {
|
|
88
|
+
if (!before || !after) return []
|
|
89
|
+
return ['format', 'executable', 'entry'].filter(field => before[field] !== after[field]).map(field => ({ field, from: before[field], to: after[field] }))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Comparator: largest absolute delta first, then path/label ascending
|
|
93
|
+
// so equal-magnitude rows stay stably ordered. Used for the changed
|
|
94
|
+
// file + package lists, where the biggest mover is the most
|
|
95
|
+
// interesting row.
|
|
96
|
+
function byAbsDeltaThenKey(key) {
|
|
97
|
+
return (a, b) => {
|
|
98
|
+
const d = Math.abs(b.delta) - Math.abs(a.delta)
|
|
99
|
+
// `d || …` falls through to the tiebreak only when the magnitudes
|
|
100
|
+
// match (d === 0); a non-zero d is returned as-is.
|
|
101
|
+
return d || a[key].localeCompare(b[key])
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Largest byte size first, then key ascending — for the only-in-one-
|
|
106
|
+
// side lists, where there's no delta to rank by.
|
|
107
|
+
function byBytesThenKey(key) {
|
|
108
|
+
return (a, b) => {
|
|
109
|
+
const d = b.bytes - a.bytes
|
|
110
|
+
return d || a[key].localeCompare(b[key])
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A file's content as compareTrees reads it to find renames, each kind tagged
|
|
115
|
+
// apart: text as it is (a lone surrogate, which no UTF-8 holds, as U+FFFD); a
|
|
116
|
+
// resource by its spelling or its digest; and one with none, by its place in
|
|
117
|
+
// its list alone, so that only its path pairs it.
|
|
118
|
+
function treeData(content, at) {
|
|
119
|
+
if (typeof content === 'string') return `\0text\0${content.toWellFormed()}`
|
|
120
|
+
if (content?.format === 'base64') return `\0base64\0${content.data}`
|
|
121
|
+
return content?.format === 'digest' ? `\0digest\0${content.digest}` : `\0none\0${at}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// git's diff.renameLimit for a package's renames: past this many removed files
|
|
125
|
+
// by added ones, squared, no pair is scored, as that would hold the page for
|
|
126
|
+
// seconds, and only identical content and paths pair them.
|
|
127
|
+
const RENAME_LIMIT = 250
|
|
128
|
+
|
|
129
|
+
// The files of `onlyBase` renamed to one of `onlyOther`, both by path as the
|
|
130
|
+
// other bundle lays them out, within a package: `Map<base path, other path>`,
|
|
131
|
+
// as @preventive/compare's compareTrees finds them, by content as git diff -M
|
|
132
|
+
// does and where their paths say so. Whether a renamed file changed is
|
|
133
|
+
// sameContent's to say, not this reading's. A path no UTF-8 holds is left out.
|
|
134
|
+
function renamesOf(onlyBase, onlyOther, base, other, pkgOf) {
|
|
135
|
+
const entries = (rows, files, side) => rows.filter(({ path }) => path !== '' && path.isWellFormed())
|
|
136
|
+
.map(({ path }, at) => ({ name: path, data: treeData(files.get(path), `${side}${at}`) }))
|
|
137
|
+
const options = { paths: 'keys', renameGroup: pkgOf, renameLimit: RENAME_LIMIT, lines: false }
|
|
138
|
+
const changes = compareTrees(entries(onlyBase, base, 'a'), entries(onlyOther, other, 'b'), options)
|
|
139
|
+
return new Map(changes.filter(({ status }) => status === 'renamed').map(change => [change.old.path, change.new.path]))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A base path through `root` as treeLayout reads it, or null for one outside
|
|
143
|
+
// a base root.
|
|
144
|
+
function through(root, path) {
|
|
145
|
+
if (!root) return path
|
|
146
|
+
if (root.base === '') return `${root.other}${path}`
|
|
147
|
+
return path.startsWith(root.base) ? path.slice(root.base.length) : null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Per-package accumulator factory. Tracks each side's byte total plus
|
|
151
|
+
// per-bucket file counts so a package is flagged `changed` whenever
|
|
152
|
+
// ANY of its files moved — not only when the byte total happens to
|
|
153
|
+
// differ (two versions can shuffle content while landing on the same
|
|
154
|
+
// size).
|
|
155
|
+
function emptyPkgAcc() {
|
|
156
|
+
return {
|
|
157
|
+
baseBytes: 0,
|
|
158
|
+
otherBytes: 0,
|
|
159
|
+
onlyBaseFiles: 0,
|
|
160
|
+
onlyOtherFiles: 0,
|
|
161
|
+
changedFiles: 0,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Compare two bundle source maps. Returns:
|
|
166
|
+
// {
|
|
167
|
+
// totals: { baseFiles, baseBytes, otherFiles, otherBytes,
|
|
168
|
+
// onlyBaseFiles, onlyBaseBytes, onlyOtherFiles,
|
|
169
|
+
// onlyOtherBytes, changedFiles, changedDelta,
|
|
170
|
+
// unchangedFiles, fileDelta, byteDelta, identical },
|
|
171
|
+
// files: {
|
|
172
|
+
// onlyBase: [{ path, bytes }],
|
|
173
|
+
// onlyOther: [{ path, bytes }],
|
|
174
|
+
// changed: [{ path, baseBytes, otherBytes, delta, basePath?, modified?, meta? }],
|
|
175
|
+
// },
|
|
176
|
+
// packages: {
|
|
177
|
+
// onlyBase: [{ pkg, bytes }],
|
|
178
|
+
// onlyOther: [{ pkg, bytes }],
|
|
179
|
+
// changed: [{ pkg, baseBytes, otherBytes, delta }],
|
|
180
|
+
// },
|
|
181
|
+
// }
|
|
182
|
+
//
|
|
183
|
+
// `delta` is always `other − base` (positive = the compared bundle is
|
|
184
|
+
// larger). A file renamed (see renamesOf) is changed, not
|
|
185
|
+
// removed and added: its row's `path` is the other side's, `renamedFrom`
|
|
186
|
+
// the base's, `modified` whether its contents changed too, and it counts in
|
|
187
|
+
// `renamedFiles` as well, its contents moved or not.
|
|
188
|
+
// Where one bundle's files sit under a directory of the other's (`root`, see
|
|
189
|
+
// treeLayout), the base's are compared as the other lays them out: a row's
|
|
190
|
+
// `path` (and `renamedFrom`) reads that way, and `basePath` names the file
|
|
191
|
+
// in the base bundle wherever that differs from `path`. `files.basePaths`
|
|
192
|
+
// maps every base file to how it reads. `layout` (treeLayout) may come
|
|
193
|
+
// from the caller, to key `pkgOf` by the same paths: it is asked of files
|
|
194
|
+
// as the other bundle lays them out.
|
|
195
|
+
// `meta`, each side's bundleFileMeta where it has one, compares what two
|
|
196
|
+
// stasis bundles record of a file beside its bytes: a row whose record
|
|
197
|
+
// changed carries how (`meta`, metaChanges), and a file whose record alone
|
|
198
|
+
// changed is changed too, `modified: false`, its bytes the same.
|
|
199
|
+
// `identical` is true when the two bundles carry the same files with
|
|
200
|
+
// byte-identical content, resources included, each recorded alike.
|
|
201
|
+
export function computeBundleDiff(base, other, pkgOf, layout = treeLayout(base.keys(), other.keys()), meta = []) {
|
|
202
|
+
const { root, paths: basePaths } = layout
|
|
203
|
+
// The base's files as the other lays them out, and back.
|
|
204
|
+
const baseFile = new Map([...basePaths].map(([from, to]) => [to, from]))
|
|
205
|
+
base = new Map([...base].map(([path, content]) => [basePaths.get(path), content]))
|
|
206
|
+
const atBase = path => baseFile.get(path) === path ? {} : { basePath: baseFile.get(path) }
|
|
207
|
+
// How a file's record changed, by its base path and its path, as a row's fields.
|
|
208
|
+
const recorded = (from, to) => {
|
|
209
|
+
const changes = metaChanges(meta[0]?.(from), meta[1]?.(to))
|
|
210
|
+
return changes.length > 0 ? { meta: changes } : {}
|
|
211
|
+
}
|
|
212
|
+
const onlyBase = []
|
|
213
|
+
const onlyOther = []
|
|
214
|
+
const changed = []
|
|
215
|
+
let baseBytes = 0
|
|
216
|
+
let otherBytes = 0
|
|
217
|
+
let onlyBaseBytes = 0
|
|
218
|
+
let onlyOtherBytes = 0
|
|
219
|
+
let changedDelta = 0
|
|
220
|
+
let unchangedFiles = 0
|
|
221
|
+
const pkgs = new Map()
|
|
222
|
+
const pkgAcc = (path) => {
|
|
223
|
+
const key = pkgOf(path)
|
|
224
|
+
let acc = pkgs.get(key)
|
|
225
|
+
if (!acc) { acc = emptyPkgAcc(); pkgs.set(key, acc) }
|
|
226
|
+
return acc
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const allPaths = new Set([...base.keys(), ...other.keys()])
|
|
230
|
+
for (const path of allPaths) {
|
|
231
|
+
const inBase = base.has(path)
|
|
232
|
+
const inOther = other.has(path)
|
|
233
|
+
const acc = pkgAcc(path)
|
|
234
|
+
if (inBase && inOther) {
|
|
235
|
+
const bC = base.get(path)
|
|
236
|
+
const oC = other.get(path)
|
|
237
|
+
const same = sameContent(bC, oC)
|
|
238
|
+
const record = recorded(baseFile.get(path), path)
|
|
239
|
+
const bB = byteLen(bC)
|
|
240
|
+
// Identical content shares the byte count, so only measure the
|
|
241
|
+
// other side when the files actually differ.
|
|
242
|
+
const oB = same ? bB : byteLen(oC)
|
|
243
|
+
baseBytes += bB
|
|
244
|
+
otherBytes += oB
|
|
245
|
+
acc.baseBytes += bB
|
|
246
|
+
acc.otherBytes += oB
|
|
247
|
+
if (same && !record.meta) {
|
|
248
|
+
unchangedFiles++
|
|
249
|
+
} else {
|
|
250
|
+
const delta = oB - bB
|
|
251
|
+
changed.push({ path, ...atBase(path), baseBytes: bB, otherBytes: oB, delta, ...record, ...same ? { modified: false } : {} })
|
|
252
|
+
changedDelta += delta
|
|
253
|
+
acc.changedFiles++
|
|
254
|
+
}
|
|
255
|
+
} else if (inBase) {
|
|
256
|
+
const bB = byteLen(base.get(path))
|
|
257
|
+
baseBytes += bB
|
|
258
|
+
onlyBaseBytes += bB
|
|
259
|
+
acc.baseBytes += bB
|
|
260
|
+
acc.onlyBaseFiles++
|
|
261
|
+
onlyBase.push({ path, ...atBase(path), bytes: bB })
|
|
262
|
+
} else {
|
|
263
|
+
const oB = byteLen(other.get(path))
|
|
264
|
+
otherBytes += oB
|
|
265
|
+
onlyOtherBytes += oB
|
|
266
|
+
acc.otherBytes += oB
|
|
267
|
+
acc.onlyOtherFiles++
|
|
268
|
+
onlyOther.push({ path, bytes: oB })
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const renames = renamesOf(onlyBase, onlyOther, base, other, pkgOf)
|
|
273
|
+
if (renames.size > 0) {
|
|
274
|
+
const renamedTo = new Set(renames.values())
|
|
275
|
+
for (const [from, to] of renames) {
|
|
276
|
+
const bC = base.get(from), oC = other.get(to)
|
|
277
|
+
const same = sameContent(bC, oC)
|
|
278
|
+
const bB = byteLen(bC), oB = same ? bB : byteLen(oC)
|
|
279
|
+
onlyBaseBytes -= bB
|
|
280
|
+
onlyOtherBytes -= oB
|
|
281
|
+
changed.push({ path: to, renamedFrom: from, basePath: baseFile.get(from), baseBytes: bB, otherBytes: oB, delta: oB - bB, modified: !same, ...recorded(baseFile.get(from), to) })
|
|
282
|
+
changedDelta += oB - bB
|
|
283
|
+
// Both in one package (its key is in the rename's).
|
|
284
|
+
const acc = pkgAcc(from)
|
|
285
|
+
acc.onlyBaseFiles--
|
|
286
|
+
acc.onlyOtherFiles--
|
|
287
|
+
acc.changedFiles++
|
|
288
|
+
}
|
|
289
|
+
onlyBase.splice(0, onlyBase.length, ...onlyBase.filter(row => !renames.has(row.path)))
|
|
290
|
+
onlyOther.splice(0, onlyOther.length, ...onlyOther.filter(row => !renamedTo.has(row.path)))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Classify each package from its accumulator: present on exactly one
|
|
294
|
+
// side → onlyBase / onlyOther; present on both with any moved file →
|
|
295
|
+
// changed; otherwise unchanged (dropped — the lists surface only
|
|
296
|
+
// what differs).
|
|
297
|
+
const pkgOnlyBase = []
|
|
298
|
+
const pkgOnlyOther = []
|
|
299
|
+
const pkgChanged = []
|
|
300
|
+
const pkgUnchanged = []
|
|
301
|
+
for (const [pkg, acc] of pkgs) {
|
|
302
|
+
// Present on a side if any of its files contributed there — its
|
|
303
|
+
// byte total, an only-this-side file, or a changed file (which
|
|
304
|
+
// exists on both). A changed file alone is enough to count as
|
|
305
|
+
// present even when that side's bytes net to zero.
|
|
306
|
+
const onBase = acc.baseBytes > 0 || acc.onlyBaseFiles > 0 || acc.changedFiles > 0
|
|
307
|
+
const onOther = acc.otherBytes > 0 || acc.onlyOtherFiles > 0 || acc.changedFiles > 0
|
|
308
|
+
const movedFiles = acc.onlyBaseFiles + acc.onlyOtherFiles + acc.changedFiles
|
|
309
|
+
if (onBase && !onOther) {
|
|
310
|
+
pkgOnlyBase.push({ pkg, bytes: acc.baseBytes })
|
|
311
|
+
} else if (onOther && !onBase) {
|
|
312
|
+
pkgOnlyOther.push({ pkg, bytes: acc.otherBytes })
|
|
313
|
+
} else if (movedFiles > 0 || acc.baseBytes !== acc.otherBytes) {
|
|
314
|
+
pkgChanged.push({
|
|
315
|
+
pkg,
|
|
316
|
+
baseBytes: acc.baseBytes,
|
|
317
|
+
otherBytes: acc.otherBytes,
|
|
318
|
+
delta: acc.otherBytes - acc.baseBytes,
|
|
319
|
+
})
|
|
320
|
+
} else {
|
|
321
|
+
pkgUnchanged.push({ pkg, bytes: acc.baseBytes })
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
onlyBase.sort(byBytesThenKey('path'))
|
|
326
|
+
onlyOther.sort(byBytesThenKey('path'))
|
|
327
|
+
changed.sort(byAbsDeltaThenKey('path'))
|
|
328
|
+
pkgOnlyBase.sort(byBytesThenKey('pkg'))
|
|
329
|
+
pkgOnlyOther.sort(byBytesThenKey('pkg'))
|
|
330
|
+
pkgChanged.sort(byAbsDeltaThenKey('pkg'))
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
totals: {
|
|
334
|
+
baseFiles: base.size,
|
|
335
|
+
baseBytes,
|
|
336
|
+
otherFiles: other.size,
|
|
337
|
+
otherBytes,
|
|
338
|
+
onlyBaseFiles: onlyBase.length,
|
|
339
|
+
onlyBaseBytes,
|
|
340
|
+
onlyOtherFiles: onlyOther.length,
|
|
341
|
+
onlyOtherBytes,
|
|
342
|
+
changedFiles: changed.length,
|
|
343
|
+
renamedFiles: renames.size,
|
|
344
|
+
changedDelta,
|
|
345
|
+
unchangedFiles,
|
|
346
|
+
fileDelta: other.size - base.size,
|
|
347
|
+
byteDelta: otherBytes - baseBytes,
|
|
348
|
+
identical: onlyBase.length === 0 && onlyOther.length === 0 && changed.length === 0,
|
|
349
|
+
},
|
|
350
|
+
files: { onlyBase, onlyOther, changed, root, basePaths },
|
|
351
|
+
// `unchanged` lends a version-only change its (equal) sizes.
|
|
352
|
+
packages: { onlyBase: pkgOnlyBase, onlyOther: pkgOnlyOther, changed: pkgChanged, unchanged: pkgUnchanged },
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The Overview's package rows: each package that differs, once, from its
|
|
357
|
+
// sizes (computeBundleDiff's `packages`) and the versions each bundle
|
|
358
|
+
// records for it (`bundlePackageVersions`). A package only one side has is
|
|
359
|
+
// removed or added, with its size and versions there; one both have is
|
|
360
|
+
// changed when its size or its versions moved, with both sides of each —
|
|
361
|
+
// own source and workspace modules carry sizes alone.
|
|
362
|
+
export function comparePackages(packages, baseVersions, otherVersions) {
|
|
363
|
+
const sizes = new Map()
|
|
364
|
+
for (const r of packages.onlyBase) sizes.set(r.pkg, { base: r.bytes, other: null })
|
|
365
|
+
for (const r of packages.onlyOther) sizes.set(r.pkg, { base: null, other: r.bytes })
|
|
366
|
+
for (const r of packages.changed) sizes.set(r.pkg, { base: r.baseBytes, other: r.otherBytes })
|
|
367
|
+
for (const r of packages.unchanged ?? []) sizes.set(r.pkg, { base: r.bytes, other: r.bytes })
|
|
368
|
+
const versionsOf = (map, pkg) => [...map.get(pkg) ?? []].toSorted(compareSemver)
|
|
369
|
+
const moved = new Set([...packages.onlyBase, ...packages.onlyOther, ...packages.changed].map(r => r.pkg))
|
|
370
|
+
for (const pkg of new Set([...baseVersions.keys(), ...otherVersions.keys()])) {
|
|
371
|
+
if (!sameVersions(versionsOf(baseVersions, pkg), versionsOf(otherVersions, pkg))) moved.add(pkg)
|
|
372
|
+
}
|
|
373
|
+
const added = [], changed = [], removed = []
|
|
374
|
+
for (const pkg of moved) {
|
|
375
|
+
const size = sizes.get(pkg) ?? { base: null, other: null }
|
|
376
|
+
const after = versionsOf(otherVersions, pkg), before = versionsOf(baseVersions, pkg)
|
|
377
|
+
const onBase = size.base !== null || before.length > 0
|
|
378
|
+
const onOther = size.other !== null || after.length > 0
|
|
379
|
+
if (onBase && !onOther) removed.push({ pkg, bytes: size.base, versions: before })
|
|
380
|
+
else if (onOther && !onBase) added.push({ pkg, bytes: size.other, versions: after })
|
|
381
|
+
else {
|
|
382
|
+
changed.push({
|
|
383
|
+
pkg, baseBytes: size.base, otherBytes: size.other,
|
|
384
|
+
delta: size.base !== null && size.other !== null ? size.other - size.base : null,
|
|
385
|
+
baseVersions: before, otherVersions: after,
|
|
386
|
+
direction: before.length > 0 && after.length > 0 && !sameVersions(before, after) ? versionDirection(before, after) : null,
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const byPkg = (a, b) => a.pkg.localeCompare(b.pkg)
|
|
391
|
+
return { removed: removed.toSorted(byPkg), added: added.toSorted(byPkg), changed: changed.toSorted(byPkg) }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Split a version into its dotted-numeric core and its prerelease tail,
|
|
395
|
+
// dropping any `+build` metadata (semver ignores it for precedence).
|
|
396
|
+
// `1.2.3-rc.1+sha` → { core: '1.2.3', pre: 'rc.1' }.
|
|
397
|
+
function splitVersion(v) {
|
|
398
|
+
const s = String(v).trim()
|
|
399
|
+
const noBuild = s.split('+', 1)[0]
|
|
400
|
+
const dash = noBuild.indexOf('-')
|
|
401
|
+
return dash === -1
|
|
402
|
+
? { core: noBuild, pre: '' }
|
|
403
|
+
: { core: noBuild.slice(0, dash), pre: noBuild.slice(dash + 1) }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Lightweight semver-ish comparison, enough to sort versions ascending
|
|
407
|
+
// and label an update ↑/↓. Compares the dotted-numeric core part by
|
|
408
|
+
// part (a missing part counts as 0); on an equal core a release ranks
|
|
409
|
+
// above a prerelease (`1.0.0` > `1.0.0-rc.1`) and two prereleases
|
|
410
|
+
// compare by their dot identifiers (numeric where both are numeric,
|
|
411
|
+
// else lexical) per the semver precedence rules. NOT a full
|
|
412
|
+
// implementation — a non-numeric core segment falls back to a string
|
|
413
|
+
// compare of the whole version so ordering stays total and stable.
|
|
414
|
+
export function compareSemver(a, b) {
|
|
415
|
+
if (a === b) return 0
|
|
416
|
+
const A = splitVersion(a)
|
|
417
|
+
const B = splitVersion(b)
|
|
418
|
+
const ap = A.core.split('.')
|
|
419
|
+
const bp = B.core.split('.')
|
|
420
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
421
|
+
const x = Number(ap[i] ?? 0)
|
|
422
|
+
const y = Number(bp[i] ?? 0)
|
|
423
|
+
if (Number.isNaN(x) || Number.isNaN(y)) return a < b ? -1 : 1
|
|
424
|
+
if (x !== y) return x < y ? -1 : 1
|
|
425
|
+
}
|
|
426
|
+
if (A.pre === B.pre) return 0
|
|
427
|
+
// A core with no prerelease is the higher (released) version.
|
|
428
|
+
if (!A.pre) return 1
|
|
429
|
+
if (!B.pre) return -1
|
|
430
|
+
const ai = A.pre.split('.')
|
|
431
|
+
const bi = B.pre.split('.')
|
|
432
|
+
for (let i = 0; i < Math.max(ai.length, bi.length); i++) {
|
|
433
|
+
const x = ai[i]
|
|
434
|
+
const y = bi[i]
|
|
435
|
+
if (x === undefined) return -1
|
|
436
|
+
if (y === undefined) return 1
|
|
437
|
+
if (x === y) continue
|
|
438
|
+
const xNum = /^\d+$/u.test(x)
|
|
439
|
+
const yNum = /^\d+$/u.test(y)
|
|
440
|
+
// Numeric identifiers always rank below non-numeric ones (semver),
|
|
441
|
+
// and compare numerically against each other.
|
|
442
|
+
if (xNum && yNum) return Number(x) < Number(y) ? -1 : 1
|
|
443
|
+
if (xNum !== yNum) return xNum ? -1 : 1
|
|
444
|
+
return x < y ? -1 : 1
|
|
445
|
+
}
|
|
446
|
+
return 0
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// True when two ascending-sorted version arrays hold the same members.
|
|
450
|
+
function sameVersions(a, b) {
|
|
451
|
+
if (a.length !== b.length) return false
|
|
452
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
453
|
+
return true
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Direction of a version move, from the versions dropped and the versions
|
|
457
|
+
// taken on: 'up' when everything dropped is older than everything taken
|
|
458
|
+
// on (`1.0.0` → `2.0.0`), or, with only drops or only additions, than the
|
|
459
|
+
// versions kept (`1.0.0, 2.0.0` → `2.0.0` drops the older copy, and a newer
|
|
460
|
+
// copy added beside the old is up too); 'down' the other way round; and
|
|
461
|
+
// 'changed' when the moves fall between (`1.0.0, 1.2.0, 2.0.0` → `1.0.0,
|
|
462
|
+
// 2.0.0`).
|
|
463
|
+
export function versionDirection(baseVersions, otherVersions) {
|
|
464
|
+
const dropped = baseVersions.filter(v => !otherVersions.includes(v)).toSorted(compareSemver)
|
|
465
|
+
const taken = otherVersions.filter(v => !baseVersions.includes(v)).toSorted(compareSemver)
|
|
466
|
+
const kept = baseVersions.filter(v => otherVersions.includes(v)).toSorted(compareSemver)
|
|
467
|
+
const [from, to] = dropped.length > 0 && taken.length > 0 ? [dropped, taken]
|
|
468
|
+
: dropped.length > 0 ? [dropped, kept] : [kept, taken]
|
|
469
|
+
if (from.length === 0 || to.length === 0) return 'changed'
|
|
470
|
+
// Every version of `a` older than every version of `b`.
|
|
471
|
+
const before = (a, b) => compareSemver(a.at(-1), b[0]) < 0
|
|
472
|
+
return before(from, to) ? 'up' : before(to, from) ? 'down' : 'changed'
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Diff two `Map<packageName, Set<version>>` inventories (as
|
|
476
|
+
// `bundlePackageVersions` returns for each bundle) into the dependency
|
|
477
|
+
// version changes between them. Mirrors the file/package diff framing:
|
|
478
|
+
// `base` is the open bundle, `other` the one compared against.
|
|
479
|
+
//
|
|
480
|
+
// {
|
|
481
|
+
// updated: [{ pkg, baseVersions: [...], otherVersions: [...],
|
|
482
|
+
// direction: 'up' | 'down' | 'changed' }],
|
|
483
|
+
// added: [{ pkg, versions: [...] }], // dependency only in other
|
|
484
|
+
// removed: [{ pkg, versions: [...] }], // dependency only in base
|
|
485
|
+
// totals: { baseDeps, otherDeps }, // distinct package counts
|
|
486
|
+
// }
|
|
487
|
+
//
|
|
488
|
+
// `updated` is the headline — a package present on BOTH sides whose set
|
|
489
|
+
// of versions changed (the "what did this bump pull in?" answer);
|
|
490
|
+
// packages whose versions are byte-for-byte the same are dropped.
|
|
491
|
+
// Version arrays are sorted ascending; the three lists are sorted by
|
|
492
|
+
// package name so the dependency list reads alphabetically.
|
|
493
|
+
export function computeVersionUpdates(baseVersions, otherVersions) {
|
|
494
|
+
const updated = []
|
|
495
|
+
const added = []
|
|
496
|
+
const removed = []
|
|
497
|
+
const names = new Set([...baseVersions.keys(), ...otherVersions.keys()])
|
|
498
|
+
for (const pkg of names) {
|
|
499
|
+
const bSet = baseVersions.get(pkg)
|
|
500
|
+
const oSet = otherVersions.get(pkg)
|
|
501
|
+
if (bSet && oSet) {
|
|
502
|
+
const b = [...bSet].toSorted(compareSemver)
|
|
503
|
+
const o = [...oSet].toSorted(compareSemver)
|
|
504
|
+
if (sameVersions(b, o)) continue
|
|
505
|
+
updated.push({ pkg, baseVersions: b, otherVersions: o, direction: versionDirection(b, o) })
|
|
506
|
+
} else if (bSet) {
|
|
507
|
+
removed.push({ pkg, versions: [...bSet].toSorted(compareSemver) })
|
|
508
|
+
} else {
|
|
509
|
+
added.push({ pkg, versions: [...oSet].toSorted(compareSemver) })
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const byPkg = (a, b) => a.pkg.localeCompare(b.pkg)
|
|
513
|
+
updated.sort(byPkg)
|
|
514
|
+
added.sort(byPkg)
|
|
515
|
+
removed.sort(byPkg)
|
|
516
|
+
return {
|
|
517
|
+
updated,
|
|
518
|
+
added,
|
|
519
|
+
removed,
|
|
520
|
+
totals: { baseDeps: baseVersions.size, otherDeps: otherVersions.size },
|
|
521
|
+
}
|
|
522
|
+
}
|