@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-45WIN3SW.js +1 -0
  46. package/out/chunk-4XCDFGDP.js +10 -0
  47. package/out/chunk-6C76GAXK.js +1 -0
  48. package/out/chunk-7ZKDKGAH.js +1 -0
  49. package/out/chunk-ABW5FVSN.js +1 -0
  50. package/out/chunk-C4Z6ZBXV.js +10 -0
  51. package/out/chunk-CMQA4DM6.js +1 -0
  52. package/out/chunk-CVVJAXVY.js +2 -0
  53. package/out/chunk-DIJJNTRH.js +10 -0
  54. package/out/chunk-F4OMDNEY.js +1 -0
  55. package/out/chunk-GJ4IUCNS.js +1 -0
  56. package/out/chunk-GMFXFEUA.js +2 -0
  57. package/out/chunk-GNXOKIGP.js +80 -0
  58. package/out/chunk-GZ4MS3TE.js +13 -0
  59. package/out/chunk-HIV7SVI7.js +1 -0
  60. package/out/chunk-IU3CCINR.js +3 -0
  61. package/out/chunk-JVBB4LZX.js +1 -0
  62. package/out/chunk-K3X5EJBQ.js +2 -0
  63. package/out/chunk-KEX2ATEW.js +2 -0
  64. package/out/chunk-KYCNJVB7.js +1 -0
  65. package/out/chunk-NRPC74AM.js +1 -0
  66. package/out/chunk-OOJM4CTU.js +1 -0
  67. package/out/chunk-PNHBZGDD.js +1 -0
  68. package/out/chunk-QSLWGJBQ.js +223 -0
  69. package/out/chunk-R5O6FAW4.js +2 -0
  70. package/out/chunk-RFZQQXSG.js +17 -0
  71. package/out/chunk-TINZZKP5.js +33 -0
  72. package/out/chunk-TUII34K2.js +1 -0
  73. package/out/chunk-TZ5KB2Y5.js +10 -0
  74. package/out/chunk-UNTRGE26.js +2 -0
  75. package/out/chunk-UYAXVRVL.js +1 -0
  76. package/out/chunk-V3ME6FI5.js +2 -0
  77. package/out/chunk-V5O7M2YT.js +1 -0
  78. package/out/chunk-VIHGO77Y.js +1 -0
  79. package/out/chunk-W4J3MGLB.js +1 -0
  80. package/out/chunk-WGG7KQUH.js +1 -0
  81. package/out/chunk-XNGEQO7Y.js +2 -0
  82. package/out/chunk-XRWAV3DX.js +7 -0
  83. package/out/chunk-XYDGU6BM.js +51 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +89 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +171 -0
  100. package/risk/files/runs-code.js +358 -0
  101. package/risk/files/script.js +114 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +228 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +34 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +74 -12
  129. package/server-managed/bundle-cache.ts +37 -4
  130. package/server-managed/bundle-commits.ts +140 -0
  131. package/server-managed/bundle-summary-cache.ts +24 -10
  132. package/server-managed/config.ts +4 -0
  133. package/server-managed/db-methods.ts +98 -11
  134. package/server-managed/db-neon.ts +39 -4
  135. package/server-managed/db-schema.ts +14 -2
  136. package/server-managed/db-table-names.ts +1 -1
  137. package/server-managed/db.ts +7 -3
  138. package/server-managed/dependency-reviews.ts +248 -0
  139. package/server-managed/github-app.ts +9 -3
  140. package/server-managed/github-errors.ts +21 -2
  141. package/server-managed/github-issue-oauth.ts +5 -6
  142. package/server-managed/github-metadata.ts +115 -0
  143. package/server-managed/github-oauth.ts +38 -2
  144. package/server-managed/http-response.ts +26 -0
  145. package/server-managed/http.ts +952 -53
  146. package/server-managed/index.ts +11 -5
  147. package/server-managed/kept.ts +28 -0
  148. package/server-managed/locked-packages.ts +85 -0
  149. package/server-managed/lru.ts +30 -0
  150. package/server-managed/npm-insights.ts +440 -0
  151. package/server-managed/npm-kept.ts +65 -0
  152. package/server-managed/npm-loads.ts +167 -0
  153. package/server-managed/npm-manifest.ts +115 -0
  154. package/server-managed/npm-packages.ts +378 -0
  155. package/server-managed/npm-provenance.ts +123 -0
  156. package/server-managed/npm-risk.ts +177 -0
  157. package/server-managed/pretty-print.ts +352 -0
  158. package/server-managed/public-workspace.ts +78 -7
  159. package/server-managed/rename-diff-worker.js +26 -0
  160. package/server-managed/rename-diff.ts +270 -0
  161. package/server-managed/report-sources.ts +13 -2
  162. package/server-managed/repository-browser.ts +19 -5
  163. package/server-managed/repository-risk.ts +418 -0
  164. package/server-managed/session.ts +16 -2
  165. package/server-managed/sql.ts +5 -2
  166. package/server-managed/storage-encryption.ts +5 -3
  167. package/server-managed/storage-payload.ts +4 -0
  168. package/server-managed/storage-stores.ts +2 -0
  169. package/server-managed/storage.ts +6 -3
  170. package/server-managed/team-app.ts +94 -0
  171. package/server-managed/team-feed.ts +1 -1
  172. package/server-managed/team-npm-scopes.ts +77 -0
  173. package/server-managed/type-only-worker.js +19 -0
  174. package/server-managed/type-only.ts +103 -0
  175. package/server-managed/uploads.ts +25 -4
  176. package/server-managed/upstream-cache.ts +147 -5
  177. package/server-managed/view-as.ts +58 -0
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @preventive/triage
2
+
3
+ [![](https://img.shields.io/npm/v/@preventive/triage?style=flat-square)](https://npmjs.org/package/@preventive/triage)
4
+ [![](https://img.shields.io/npm/l/@preventive/triage?style=flat-square&color=blue)](#license)
5
+
6
+ Client & relay server for triaging automated reports.
7
+
8
+ ```sh
9
+ npx -p @preventive/triage triage-server --mode e2e # or managed, managed-e2e, e2e-managed
10
+ ```
11
+
12
+ `triage-server --help` lists the environment each mode reads.
13
+
14
+ ## License
15
+
16
+ [MIT](./LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE), at your option.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import type { BundleDetails } from './bundle-metadata.js'
2
+ export function bundleCompareFiles(details: BundleDetails, scope?: string | null): Map<string, unknown>
@@ -0,0 +1,65 @@
1
+ import { bundleFilesAsMap } from './bundle-sources.js'
2
+
3
+ // Use the union of named trees. A tree absent from one stasis bundle contains
4
+ // no files there; falling back to all files would invent additions/removals.
5
+ // A bundle that names no trees (a sourcemap) is compared whole under any of
6
+ // them. Unlike a single-bundle graph, retain a scope that includes every file
7
+ // on one side.
8
+ export function bundleCompareScopes(...details) {
9
+ const names = new Set()
10
+ for (const item of details) {
11
+ const files = bundleFilesAsMap(item)
12
+ for (const [name, paths] of Object.entries(item?.bundle?.reason ?? {})) {
13
+ if (Array.isArray(paths) && paths.some(path => files.has(path))) names.add(name)
14
+ }
15
+ }
16
+ // A scope is only useful as a filter if it changes the files compared on
17
+ // some side. Keep every scope when at least one does, otherwise hide the
18
+ // selector, as single-bundle views do (see common/bundle-reasons.js).
19
+ const narrows = name => details.some(item => {
20
+ if (item?.kind !== 'stasis') return false
21
+ const files = bundleFilesAsMap(item)
22
+ const paths = item?.bundle?.reason?.[name]
23
+ return new Set(Array.isArray(paths) ? paths.filter(path => files.has(path)) : []).size < files.size
24
+ })
25
+ if (![...names].some(narrows)) return []
26
+ return [...names].toSorted().map(name => ({ id: `reason:${name}`, label: name }))
27
+ }
28
+
29
+ export function bundleCompareFiles(details, scope = '') {
30
+ const files = bundleFilesAsMap(details)
31
+ if (!scope || details?.kind !== 'stasis') return files
32
+ const paths = details?.bundle?.reason?.[scope.replace(/^reason:/u, '')]
33
+ const selected = new Set(Array.isArray(paths) ? paths : [])
34
+ return new Map([...files].filter(([path]) => selected.has(path)))
35
+ }
36
+
37
+ // A resolution's identity: its importer, specifier, conditions (import
38
+ // attributes included), and Metro platform.
39
+ export function resolutionKey(parent, specifier, conditions, platform) {
40
+ return JSON.stringify([parent, specifier, conditions, platform])
41
+ }
42
+
43
+ // Keep the full resolution identity: collapsing to graph edges would lose
44
+ // specifier, condition/import-attribute, and Metro platform changes.
45
+ export function bundleCompareResolutions(details, scope = '') {
46
+ const result = new Map()
47
+ if (details?.kind !== 'stasis' || !details.bundle) return result
48
+ const files = scope ? bundleFilesAsMap(details) : null
49
+ const selected = scope ? bundleCompareFiles(details, scope) : null
50
+ for (const [conditions, byParent] of details.bundle.imports) {
51
+ for (const [parent, specifiers] of byParent) {
52
+ for (const [specifier, resolved] of specifiers) {
53
+ const targets = typeof resolved === 'string' ? [[null, resolved]] : resolved
54
+ for (const [platform, target] of targets) {
55
+ // Include imports from scoped files, plus imports into the scope
56
+ // from uncaptured parents (e.g. an app in a dependencies-only bundle).
57
+ if (selected && !selected.has(parent) && (files.has(parent) || !selected.has(target))) continue
58
+ const key = resolutionKey(parent, specifier, conditions, platform)
59
+ result.set(key, { key, parent, specifier, conditions, platform, target })
60
+ }
61
+ }
62
+ }
63
+ }
64
+ return result
65
+ }
@@ -1,10 +1,14 @@
1
+ import type { SourceMap } from '@preventive/sourcemap'
2
+ import type { SourcemapEdge } from './bundle-sourcemap.js'
1
3
  export const BUNDLE_METADATA_VERSION: number
4
+ export const FILE_HASH: RegExp
2
5
  export interface BundleIdentity { integrity: string; kind: string | null; size: number }
3
- export interface BundleDetails extends BundleIdentity { bundle?: unknown; json?: unknown }
6
+ export interface BundleDetails extends BundleIdentity { bundle?: unknown; json?: unknown; map?: SourceMap; edges?: readonly SourcemapEdge[] }
4
7
  export function parseBundleContents(text: string, identity: BundleIdentity): BundleDetails
5
8
  export interface BundleMetadata extends Record<string, unknown> {
6
9
  files: [string, number | null, string | null, number | null][]
7
10
  codeStats: { files: number; lines: number; bytes: number }
8
11
  }
9
12
  export function createBundleMetadata(details: BundleDetails): Promise<BundleMetadata>
10
- export function createBundleSummary(details: BundleDetails, metadata?: BundleMetadata): { files: number; codeFiles: number; lines: number; commit?: string }
13
+ export function parseBundleMetadata(data: unknown, integrity: string): BundleDetails
14
+ export function createBundleSummary(details: BundleDetails, metadata?: BundleMetadata): { files: number; codeFiles: number; lines: number; commit?: string; stasisVersion?: number }