@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.
Files changed (175) 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-4XCDFGDP.js +10 -0
  46. package/out/chunk-5FKRIJGF.js +1 -0
  47. package/out/chunk-6Z5XLSPJ.js +1 -0
  48. package/out/chunk-ABW5FVSN.js +1 -0
  49. package/out/chunk-BJKRVROW.js +1 -0
  50. package/out/chunk-BYUEDJOM.js +7 -0
  51. package/out/chunk-C4Z6ZBXV.js +10 -0
  52. package/out/chunk-CMQA4DM6.js +1 -0
  53. package/out/chunk-CVVJAXVY.js +2 -0
  54. package/out/chunk-DCQX2J3C.js +223 -0
  55. package/out/chunk-DIJJNTRH.js +10 -0
  56. package/out/chunk-F4OMDNEY.js +1 -0
  57. package/out/chunk-FU4MTW7F.js +1 -0
  58. package/out/chunk-GJ4IUCNS.js +1 -0
  59. package/out/chunk-GMFXFEUA.js +2 -0
  60. package/out/chunk-GNXOKIGP.js +80 -0
  61. package/out/chunk-GZ4MS3TE.js +13 -0
  62. package/out/chunk-HIV7SVI7.js +1 -0
  63. package/out/chunk-IU3CCINR.js +3 -0
  64. package/out/chunk-JAKWIZWL.js +2 -0
  65. package/out/chunk-K3X5EJBQ.js +2 -0
  66. package/out/chunk-KEX2ATEW.js +2 -0
  67. package/out/chunk-KYCNJVB7.js +1 -0
  68. package/out/chunk-NRPC74AM.js +1 -0
  69. package/out/chunk-OJ5DGP5E.js +33 -0
  70. package/out/chunk-OOJM4CTU.js +1 -0
  71. package/out/chunk-PNHBZGDD.js +1 -0
  72. package/out/chunk-R5O6FAW4.js +2 -0
  73. package/out/chunk-RFZQQXSG.js +17 -0
  74. package/out/chunk-SD2WJAXN.js +48 -0
  75. package/out/chunk-TUII34K2.js +1 -0
  76. package/out/chunk-TZ5KB2Y5.js +10 -0
  77. package/out/chunk-UNTRGE26.js +2 -0
  78. package/out/chunk-UYAXVRVL.js +1 -0
  79. package/out/chunk-V3ME6FI5.js +2 -0
  80. package/out/chunk-V5O7M2YT.js +1 -0
  81. package/out/chunk-VIHGO77Y.js +1 -0
  82. package/out/chunk-W4J3MGLB.js +1 -0
  83. package/out/chunk-WGG7KQUH.js +1 -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 +88 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +164 -0
  100. package/risk/files/runs-code.js +329 -0
  101. package/risk/files/script.js +112 -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 +222 -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 +33 -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 +68 -11
  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-issue-oauth.ts +5 -6
  140. package/server-managed/github-metadata.ts +115 -0
  141. package/server-managed/github-oauth.ts +38 -2
  142. package/server-managed/http-response.ts +26 -0
  143. package/server-managed/http.ts +943 -52
  144. package/server-managed/index.ts +11 -5
  145. package/server-managed/kept.ts +28 -0
  146. package/server-managed/locked-packages.ts +85 -0
  147. package/server-managed/lru.ts +30 -0
  148. package/server-managed/npm-insights.ts +440 -0
  149. package/server-managed/npm-kept.ts +65 -0
  150. package/server-managed/npm-loads.ts +167 -0
  151. package/server-managed/npm-manifest.ts +115 -0
  152. package/server-managed/npm-packages.ts +378 -0
  153. package/server-managed/npm-provenance.ts +123 -0
  154. package/server-managed/npm-risk.ts +177 -0
  155. package/server-managed/pretty-print.ts +352 -0
  156. package/server-managed/public-workspace.ts +78 -7
  157. package/server-managed/rename-diff-worker.js +26 -0
  158. package/server-managed/rename-diff.ts +270 -0
  159. package/server-managed/report-sources.ts +13 -2
  160. package/server-managed/repository-browser.ts +19 -5
  161. package/server-managed/repository-risk.ts +413 -0
  162. package/server-managed/session.ts +16 -2
  163. package/server-managed/sql.ts +5 -2
  164. package/server-managed/storage-encryption.ts +5 -3
  165. package/server-managed/storage-payload.ts +4 -0
  166. package/server-managed/storage-stores.ts +2 -0
  167. package/server-managed/storage.ts +6 -3
  168. package/server-managed/team-app.ts +94 -0
  169. package/server-managed/team-feed.ts +1 -1
  170. package/server-managed/team-npm-scopes.ts +77 -0
  171. package/server-managed/type-only-worker.js +19 -0
  172. package/server-managed/type-only.ts +103 -0
  173. package/server-managed/uploads.ts +25 -4
  174. package/server-managed/upstream-cache.ts +147 -5
  175. package/server-managed/view-as.ts +58 -0
@@ -4,6 +4,7 @@ import { Bundle } from '@exodus/stasis-core/bundle'
4
4
  import { computeFileHash } from '@preventive/report'
5
5
  import { utf8ByteLength } from './utf8.js'
6
6
  import { bundleFileSizes, bundleSourcesAsMap, bundleUnsizedFiles } from './bundle-sources.js'
7
+ import { bundleSourcemapEdges, parseSourcemap, sourcemapEntries } from './bundle-sourcemap.js'
7
8
 
8
9
  // Version 2 sizes every file by its bytes, resources included. A version 1
9
10
  // index sized by what the source map held when it was written: before the
@@ -13,7 +14,11 @@ import { bundleFileSizes, bundleSourcesAsMap, bundleUnsizedFiles } from './bundl
13
14
  // hashes alone and rebuilt on open.
14
15
  // Version 3 also retains the bundle's repository and package origin metadata.
15
16
  // Version 4 retains dependency repositories as well.
16
- export const BUNDLE_METADATA_VERSION = 4
17
+ // Version 5 counts lines of code, leaving blank lines out.
18
+ // Version 6 reads a sourcemap whole (bundle-sourcemap.js): an index map's
19
+ // files too, each once, keyed with `sourceRoot` in front, and the edges
20
+ // between them.
21
+ export const BUNDLE_METADATA_VERSION = 6
17
22
  const INDEX_VERSION = BUNDLE_METADATA_VERSION
18
23
  const hashJobs = new WeakMap()
19
24
  const SOURCE_TABS = new Set(['terminal', 'code', 'search', 'compare'])
@@ -22,6 +27,9 @@ export function bundleNeedsSources(tab, sourceFile = null) {
22
27
  return Boolean(sourceFile) || SOURCE_TABS.has(tab)
23
28
  }
24
29
 
30
+ // A file's hash as metadata names it: computeFileHash's sha512 of its text.
31
+ export const FILE_HASH = /^sha512-[A-Za-z0-9+/]{86}==$/u
32
+
25
33
  // Bound outstanding WebCrypto jobs: serial awaits incur one task round-trip
26
34
  // per file, while an unbounded Promise.all holds every encoded body at once.
27
35
  export function computeBundleFileHashes(details) {
@@ -42,13 +50,34 @@ export function computeBundleFileHashes(details) {
42
50
  return job
43
51
  }
44
52
 
45
- // Count source lines without charging a trailing newline as an extra line.
53
+ // Count lines of code: lines with anything but whitespace on them, comments
54
+ // included for now, split at \r\n, \r or \n. Each such line is counted at
55
+ // its first non-whitespace character, its rest passed over to the next line
56
+ // break: no match runs a line's length, which past some 9 million characters
57
+ // (a bundled payload's one line, as Shai-Hulud's 10 MB bun_environment.js)
58
+ // overflows the stack a Unicode regular expression backtracks with in a
59
+ // string holding any character past U+00FF.
46
60
  // The source map intentionally excludes non-text resources, so a null entry
47
61
  // remains the metadata marker for images, fonts, and other binary payloads.
48
62
  export function bundleSourceLineCount(content) {
49
- if (typeof content !== 'string' || content.length === 0) return 0
50
- const breaks = content.match(/\r\n|\r|\n/gu)?.length ?? 0
51
- return breaks + (/[\r\n]$/u.test(content) ? 0 : 1)
63
+ if (typeof content !== 'string') return 0
64
+ const code = /\S/gu, end = /[\r\n]/gu
65
+ let lines = 0
66
+ while (code.test(content)) {
67
+ lines++
68
+ end.lastIndex = code.lastIndex
69
+ if (!end.test(content)) break
70
+ code.lastIndex = end.lastIndex
71
+ }
72
+ return lines
73
+ }
74
+
75
+ // Each source's lines of code, by path: the index's where `details` has them,
76
+ // else counted once and kept on it. `bundleSourcesAsMap` holds only textual
77
+ // sources: Stasis resources (images, fonts, and other binary payloads) have
78
+ // no lines, so they cannot distort the language shares.
79
+ export function bundleLineCounts(details) {
80
+ return details.lineCounts ??= new Map([...bundleSourcesAsMap(details)].map(([path, content]) => [path, bundleSourceLineCount(content)]))
52
81
  }
53
82
 
54
83
  function mapObject(value) {
@@ -64,19 +93,23 @@ function mapObject(value) {
64
93
  export async function createBundleMetadata(details) {
65
94
  const hashes = await computeBundleFileHashes(details)
66
95
  const sizes = bundleFileSizes(details)
67
- const sourceLines = details.lineCounts ??= new Map([...bundleSourcesAsMap(details)]
68
- .map(([path, content]) => [path, bundleSourceLineCount(content)]))
96
+ const sourceLines = bundleLineCounts(details)
69
97
  const result = { version: INDEX_VERSION, integrity: details.integrity, kind: details.kind, size: details.size, codeStats: bundleCodeStats(sourceLines, sizes),
70
98
  files: [...sizes].map(([path, size]) => [path, size, hashes.get(path) ?? null, sourceLines.get(path) ?? null]) }
71
99
  const unsized = bundleUnsizedFiles(details)
72
100
  if (unsized.size > 0) result.unsized = [...unsized]
73
101
  if (details.kind === 'sourcemap') {
74
- const { version, file, sourceRoot, names, sources = [], sourcesContent = [] } = details.json
75
- result.json = { version, file, sourceRoot, sources }
76
- // Sourcemaps can repeat a path with different/absent content. Keep the
77
- // Overview's positional inventory, alongside the path-keyed graph index.
78
- result.sourceSizes = sources.map((_, i) => typeof sourcesContent[i] === 'string' ? utf8ByteLength(sourcesContent[i]) : null)
79
- result.namesCount = names?.length ?? null
102
+ const { version, file, sourceRoot, names } = details.json
103
+ const entries = sourcemapEntries(details)
104
+ result.json = { version, file, sourceRoot, sources: entries.map(([path]) => path) }
105
+ // A map's arrays as written can repeat a path with different/absent
106
+ // content. Keep the Overview's positional inventory, alongside the
107
+ // path-keyed graph index.
108
+ result.sourceSizes = entries.map(([, content]) => typeof content === 'string' ? utf8ByteLength(content) : null)
109
+ result.namesCount = details.namesCount ?? names?.length ?? null
110
+ // `[from, to]` or `[from, to, specifier]`, `from` and `to` rows of `files`.
111
+ const rows = new Map(result.files.map(([path], i) => [path, i]))
112
+ result.edges = bundleSourcemapEdges(details).map(([from, to, ...specifier]) => [rows.get(from), rows.get(to), ...specifier])
80
113
  } else {
81
114
  const b = details.bundle
82
115
  const bundle = { version: b.version, config: b.config, repo: b.repo, package: b.package, formats: mapObject(b.formats), imports: mapObject(b.imports), reason: b.reason, executable: [...b.executable] }
@@ -109,14 +142,16 @@ export function createBundleSummary(details, metadata) {
109
142
  const commit = details.kind === 'stasis' ? bundleCommitHash(details.bundle.repo?.commit) : null
110
143
  return { files: files.length, lines,
111
144
  codeFiles: files.filter(([path]) => !['resource:base64', 'directory'].includes(formats?.get(path))).length,
112
- ...(commit ? { commit } : {}) }
145
+ ...(commit ? { commit } : {}),
146
+ // The Stasis format version: 0 for legacy bundles, which record no package versions.
147
+ ...(details.kind === 'stasis' ? { stasisVersion: details.bundle.version } : {}) }
113
148
  }
114
149
 
115
150
  // `stale` marks an index this version did not write: its hashes still
116
151
  // answer report lookups, but an open rebuilds it for current sizes, line
117
152
  // counts and origin metadata.
118
153
  export function parseBundleMetadata(data, integrity) {
119
- if (![1, 2, 3, INDEX_VERSION].includes(data?.version) || data.integrity !== integrity || !['stasis', 'sourcemap'].includes(data.kind)
154
+ if (![1, 2, 3, 4, 5, INDEX_VERSION].includes(data?.version) || data.integrity !== integrity || !['stasis', 'sourcemap'].includes(data.kind)
120
155
  || !Number.isSafeInteger(data.size) || data.size < 0 || !Array.isArray(data.files)) throw new Error('Invalid bundle metadata')
121
156
  const stale = data.version !== INDEX_VERSION
122
157
  const legacySizes = data.version === 1
@@ -126,7 +161,7 @@ export function parseBundleMetadata(data, integrity) {
126
161
  if (!Array.isArray(row) || (row.length !== 4 && !(legacySizes && row.length === 3))) throw new Error('Invalid bundle metadata file')
127
162
  const [path, size, hash, lines] = row
128
163
  if (typeof path !== 'string' || fileSizes.has(path) || (size !== null && (!Number.isSafeInteger(size) || size < 0))
129
- || (hash !== null && (typeof hash !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/u.test(hash)))
164
+ || (hash !== null && (typeof hash !== 'string' || !FILE_HASH.test(hash)))
130
165
  || (size === null && hash !== null) || (legacySizes && size !== null && hash === null)
131
166
  || (lines !== undefined && lines !== null && (!Number.isSafeInteger(lines) || lines < 0))) throw new Error('Invalid bundle metadata file')
132
167
  fileSizes.set(path, size)
@@ -159,6 +194,8 @@ export function parseBundleMetadata(data, integrity) {
159
194
  details.json = data.json
160
195
  details.sourceSizes = data.sourceSizes
161
196
  details.namesCount = data.namesCount
197
+ // An index before version 6 has none: the view reads no edges for it.
198
+ if (data.edges !== undefined) details.edges = parseSourcemapEdges(data.edges, data.files)
162
199
  }
163
200
  if (data.codeStats === undefined) details.codeStats = bundleCodeStats(lineCounts, fileSizes)
164
201
  else {
@@ -172,12 +209,18 @@ export function parseBundleMetadata(data, integrity) {
172
209
  return details
173
210
  }
174
211
 
212
+ function parseSourcemapEdges(rows, files) {
213
+ if (!Array.isArray(rows)) throw new Error('Invalid sourcemap edges')
214
+ return rows.map((row) => {
215
+ if (!Array.isArray(row) || ![2, 3].includes(row.length) || row[0] === row[1] || (row.length === 3 && typeof row[2] !== 'string')
216
+ || row.slice(0, 2).some((i) => !Number.isSafeInteger(i) || i < 0 || i >= files.length)) throw new Error('Invalid sourcemap edges')
217
+ return [files[row[0]][0], files[row[1]][0], ...row.slice(2)]
218
+ })
219
+ }
220
+
175
221
  // HTTP content encoding handles compression before this shared JSON parser.
176
222
  export function parseBundleContents(text, { integrity, kind, size }) {
177
223
  if (kind === 'stasis') return { integrity, kind, size, bundle: Bundle.parse(text) }
178
224
  if (kind !== 'sourcemap') throw new Error('Unsupported bundle format')
179
- const json = JSON.parse(text)
180
- if (!Array.isArray(json?.sources) || json.sources.some(path => typeof path !== 'string')
181
- || (json.sourcesContent !== undefined && (!Array.isArray(json.sourcesContent) || json.sourcesContent.some(content => content !== null && typeof content !== 'string')))) throw new Error('Invalid sourcemap')
182
- return { integrity, kind, size, json }
225
+ return { integrity, kind, size, ...parseSourcemap(text) }
183
226
  }
@@ -0,0 +1 @@
1
+ export function bundlePkgOf(path: string, options?: { packageDir?: string | null | undefined }): string
@@ -0,0 +1,90 @@
1
+ // Package classifier for bundle paths. Pulled out of
2
+ // `render-bundle.js` (which drags in lit + the source-viewer + a
3
+ // circular `render.js` import, none of which this pure string logic
4
+ // needs) so it's a DOM-free leaf the bundle graph / treemap / compare
5
+ // views — and the test suite — can import on its own, and the server pair a
6
+ // comparison's files as Compare does (server-managed/rename-diff.ts).
7
+
8
+ // Bucket a bundle source path into a "package":
9
+ // - files under `node_modules/<pkg>/...` return `<pkg>` (scoped names
10
+ // included), as do Solidity files under Soldeer's
11
+ // `dependencies/<pkg>/...`, the one ecosystem installing there;
12
+ // - own (first-party) source returns the single `__own__` bucket: an
13
+ // ordinary `dependencies/` folder of anything else is own code.
14
+ //
15
+ // pnpm wraps each install in
16
+ // `node_modules/.pnpm/<name>@<version>/node_modules/<name>/...` —
17
+ // matching the first occurrence would bucket every dep under `.pnpm`,
18
+ // so when we hit that synthetic dir we walk past it to the inner
19
+ // `node_modules/<pkg>` segment that names the actual package.
20
+ //
21
+ // `packageDir` is this path's authoritative stasis package directory
22
+ // (from `bundlePackageDirs` in bundle-sources.js), when one is known.
23
+ // It takes precedence over path heuristics: a module can contain its own
24
+ // `dependencies/` directory without those files becoming new packages.
25
+ // Recorded `node_modules/<pkg>` directories use the bare package name;
26
+ // other named module directories stay separate under their full dir, and
27
+ // `.` identifies own source. Only bundles without this metadata use
28
+ // the path heuristic.
29
+ //
30
+ // The result is the package's identity — what files, sizes and edges are
31
+ // grouped by — so a vendored package keeps its dir here: Cargo's
32
+ // `vendor/log` is not npm's `log`, and one bundle can carry both. It is
33
+ // `pkgLabel` that shows it by name.
34
+ export function bundlePkgOf(path, { packageDir = null } = {}) {
35
+ if (packageDir) {
36
+ if (packageDir !== '.') {
37
+ const npm = packageDir.match(/(?:^|\/)node_modules\/(@[^/]+\/[^/]+|[^/]+)$/u)
38
+ return npm && npm[1] !== '.pnpm' ? npm[1] : packageDir
39
+ }
40
+ } else {
41
+ const re = /\.sol$/iu.test(path)
42
+ ? /(?:^|\/)(?:node_modules\/(@[^/]+\/[^/]+|[^/]+)|dependencies\/(@[^/]+\/[^/]+|[^/]+)\/)/gu
43
+ : /(?:^|\/)node_modules\/(@[^/]+\/[^/]+|[^/]+)/gu
44
+ let m
45
+ while ((m = re.exec(path)) !== null) {
46
+ const name = m[1] ?? m[2]
47
+ if (name !== '.pnpm') return name
48
+ }
49
+ }
50
+ return '__own__'
51
+ }
52
+
53
+ // First-party code: own source, and named workspace modules (a recorded
54
+ // package dir outside `node_modules/`, `dependencies/` and `vendor/`).
55
+ // Workspace modules stay separate packages in `bundlePkgOf`, but are
56
+ // own code even when another workspace imports them.
57
+ export function isOwnSourcePath(path, packageDir = null) {
58
+ return bundlePkgOf(path, { packageDir }) === '__own__'
59
+ || Boolean(packageDir) && !/(?:^|\/)(?:node_modules|dependencies|vendor)(?:\/|$)/u.test(packageDir)
60
+ }
61
+
62
+ // Search order for the bundle Code rail and Search tab: own code
63
+ // first, then dependencies, each keeping the order `paths` came in.
64
+ // Matches in the bundle's own code are usually what a search is after,
65
+ // and package code shouldn't push them past the result caps.
66
+ // `packageDirs` is `bundlePackageDirs` output, or null for the path
67
+ // heuristic alone.
68
+ export function ownSourceFirst(paths, packageDirs = null) {
69
+ const own = []
70
+ const deps = []
71
+ for (const path of paths) (isOwnSourcePath(path, packageDirs?.get(path)) ? own : deps).push(path)
72
+ return [...own, ...deps]
73
+ }
74
+
75
+ // Display label for a package bucket: `__own__` is the sentinel for
76
+ // own-source (non-dependency) files, spelled out as "Own source" in
77
+ // package lists and tooltips.
78
+ //
79
+ // A vendored package is shown by the path after the (last) `vendor/`,
80
+ // which in each vendoring layout is what the package is called: `cargo
81
+ // vendor` puts a crate at `vendor/<crate>`, Composer a package at
82
+ // `vendor/<vendor>/<pkg>`, Go a module at `vendor/<module path>`. Only
83
+ // the label is shortened; the bucket keeps the dir (see `bundlePkgOf`).
84
+ // Anything but a package key (the graph asks with no package focused)
85
+ // passes through as it came.
86
+ export function pkgLabel(pkg) {
87
+ if (pkg === '__own__') return 'Own source'
88
+ if (typeof pkg !== 'string') return pkg
89
+ return pkg.match(/^(?:.*\/)?vendor\/(.+)$/u)?.[1] ?? pkg
90
+ }
@@ -1,4 +1,5 @@
1
1
  import { bundleFilesAsMap, bundlePackageDirs, bundleSourcesAsMap } from './bundle-sources.js'
2
+ import { bundleSourcemapSpecifiers } from './bundle-sourcemap.js'
2
3
 
3
4
  const indexes = new WeakMap()
4
5
  const manifests = new WeakMap()
@@ -130,6 +131,7 @@ function resolvePackageImport(scope, specifier, sources, index, recorded, direct
130
131
  // Merge conditions before linking. When exporting a restricted source set,
131
132
  // include only visible parents and replace every unavailable target with null:
132
133
  // its path must not leak, and relative fallback must still remain blocked.
134
+ // A sourcemap's are what its edges say each specifier names (bundle-sourcemap.js).
133
135
  export function bundleSourceImports(details, sources = bundleSourcesAsMap(details)) {
134
136
  const imports = new Map()
135
137
  if (details?.kind === 'stasis') {
@@ -146,6 +148,11 @@ export function bundleSourceImports(details, sources = bundleSourcesAsMap(detail
146
148
  }
147
149
  }
148
150
  }
151
+ } else if (details?.kind === 'sourcemap') {
152
+ for (const [parent, specifiers] of bundleSourcemapSpecifiers(details)) {
153
+ if (!sources.has(parent)) continue
154
+ imports.set(parent, new Map([...specifiers].map(([specifier, target]) => [specifier, sources.has(target) ? target : null])))
155
+ }
149
156
  }
150
157
  return imports
151
158
  }
@@ -0,0 +1,10 @@
1
+ import type { SourceMap } from '@preventive/sourcemap'
2
+ import type { Edge } from '@preventive/sourcemap/edges.js'
3
+ import type { BundleDetails } from './bundle-metadata.js'
4
+ export function parseSourcemap(text: string): { json: { version: unknown; file: unknown; sourceRoot: unknown }; map: SourceMap; namesCount: number | null }
5
+ export function sourcemapEntries(details: BundleDetails): [string, string | null][]
6
+ export type SourcemapEdge = [from: string, to: string] | [from: string, to: string, specifier: string]
7
+ export function sourcemapEdges(map: SourceMap, read: (map: SourceMap) => { edges: Edge[] }): SourcemapEdge[]
8
+ export function bundleSourcemapEdges(details: BundleDetails): readonly SourcemapEdge[]
9
+ export function bundleSourcemapImports(details: BundleDetails): Map<string, Set<string>>
10
+ export function bundleSourcemapSpecifiers(details: BundleDetails): Map<string, Map<string, string>>
@@ -0,0 +1,100 @@
1
+ // A sourcemap bundle as @preventive/sourcemap reads it: plain or indexed,
2
+ // each file once, with the edges between its files, which the graph draws
3
+ // and Code links follow by the specifier of each import. A file keeps the key
4
+ // it had before the package read it, the spelling the map lists it under
5
+ // (`sourceRoot` in front), so Code links, hashes and reports that name one
6
+ // still find it.
7
+ //
8
+ // Edges take a parser for most maps. The client goes without one: it reads
9
+ // them with edges-lite.js, which knows Metro's maps alone. The server reads
10
+ // every map's with oxc-parser (server-managed/bundle-cache.ts) and keeps
11
+ // them in the bundle's metadata, which a client takes over its own.
12
+ import { readSourceMap } from '@preventive/sourcemap'
13
+ import { bundleEdges as metroEdges } from '@preventive/sourcemap/edges-lite.js'
14
+
15
+ // The map as read, and what the Overview shows of the JSON: its header,
16
+ // and how many names it maps. The JSON itself is not kept, its mappings
17
+ // read and its sources the map's files.
18
+ export function parseSourcemap(text) {
19
+ // The `)]}'` line that guards a map against being run, which readSourceMap
20
+ // skips in text it parses itself.
21
+ const json = JSON.parse(text.replace(/^\)\]\}'[^\n]*\n/u, ''))
22
+ const map = readSourceMap(json)
23
+ const namesCount = json.sections
24
+ ? json.sections.reduce((count, section) => count + (section.map.names?.length ?? 0), 0)
25
+ : json.names?.length ?? null
26
+ return { json: { version: json.version, file: json.file, sourceRoot: json.sourceRoot }, map, namesCount }
27
+ }
28
+
29
+ // The map's files as `[key, content or null]`, in its order. Details with
30
+ // no map read (the npm viewer's, a map's arrays as written) list what their
31
+ // `sources` does, a repeated path repeated.
32
+ export function sourcemapEntries(details) {
33
+ if (details.map) return details.map.files.flatMap((file) => file.source === null ? [] : [[file.source, file.content]])
34
+ const contents = details.json?.sourcesContent ?? []
35
+ return (details.json?.sources ?? []).map((path, i) => [path, contents[i] ?? null])
36
+ }
37
+
38
+ // `read`'s edges between files of the map, as rows by key: `[from, to]`,
39
+ // and `[from, to, specifier]` for an import its specifier names. An edge to
40
+ // no file of it (a package left out, a builtin) leads nowhere here, and one
41
+ // to its own file is none, which metadata refuses to hold.
42
+ export function sourcemapEdges(map, read) {
43
+ const rows = new Map()
44
+ for (const { from, to, specifier } of read(map).edges) {
45
+ if (from.source === null || to?.source == null || to.source === from.source) continue
46
+ const row = typeof specifier === 'string' ? [from.source, to.source, specifier] : [from.source, to.source]
47
+ rows.set(row.join('\0'), row)
48
+ }
49
+ return [...rows.values()]
50
+ }
51
+
52
+ // edges-lite.js throws for a map that is not Metro's, or one with no
53
+ // sourcesContent to read: one with no edges the client can tell.
54
+ function liteEdges(map) {
55
+ try { return metroEdges(map) } catch { return { edges: [] } }
56
+ }
57
+
58
+ const NO_EDGES = Object.freeze([])
59
+
60
+ // The edges a view reads: those the metadata carries, else what the client
61
+ // reads of its own map. None without a map.
62
+ export function bundleSourcemapEdges(details) {
63
+ if (details?.edges) return details.edges
64
+ if (!details?.map) return NO_EDGES
65
+ details.edges = sourcemapEdges(details.map, liteEdges)
66
+ return details.edges
67
+ }
68
+
69
+ const importsCache = new WeakMap()
70
+ const specifiersCache = new WeakMap()
71
+
72
+ // The graph's: `Map<from, Set<to>>`.
73
+ export function bundleSourcemapImports(details) {
74
+ const rows = bundleSourcemapEdges(details)
75
+ if (!importsCache.has(rows)) {
76
+ const imports = new Map()
77
+ for (const [from, to] of rows) {
78
+ if (!imports.has(from)) imports.set(from, new Set())
79
+ imports.get(from).add(to)
80
+ }
81
+ importsCache.set(rows, imports)
82
+ }
83
+ return importsCache.get(rows)
84
+ }
85
+
86
+ // Code links': the file each import's specifier names, `Map<from,
87
+ // Map<specifier, to>>`.
88
+ export function bundleSourcemapSpecifiers(details) {
89
+ const rows = bundleSourcemapEdges(details)
90
+ if (!specifiersCache.has(rows)) {
91
+ const specifiers = new Map()
92
+ for (const [from, to, specifier] of rows) {
93
+ if (specifier === undefined) continue
94
+ if (!specifiers.has(from)) specifiers.set(from, new Map())
95
+ specifiers.get(from).set(specifier, to)
96
+ }
97
+ specifiersCache.set(rows, specifiers)
98
+ }
99
+ return specifiersCache.get(rows)
100
+ }
@@ -1,5 +1,7 @@
1
1
  import type { BundleDetails } from './bundle-metadata.js'
2
2
  export function bundleSourcesAsMap(details: BundleDetails): Map<string, string>
3
+ export function bundleSourceLines(content: string): number
4
+ export function bundleSourceOrder(sources: Map<string, string>): { paths: string[]; numbers: Map<string, number> }
3
5
  export function bundleFilesAsMap(details: BundleDetails): Map<string, string | { format: 'base64'; data: string }>
4
6
  export function bundlePackageDirs(details: BundleDetails): Map<string, string> | null
5
- export function bundlePackageVersions(details: BundleDetails, paths?: Iterable<string> | null): Map<string, Set<string>>
7
+ export function bundlePackageVersions(details: BundleDetails, paths?: Iterable<string> | null, keyOf?: (dir: string, info: { name: string; version: string }) => string): Map<string, Set<string>>
@@ -1,10 +1,9 @@
1
1
  // Shared helper: given a parsed bundle `details`, return a
2
2
  // Map<file, content-string> of its source files. Stasis bundles
3
3
  // expose sources via `@exodus/stasis-core`'s `Bundle.sources` getter
4
- // (flat Map<projectRelPath, content> from `.modules`); sourcemaps
5
- // split them across parallel `sources` / `sourcesContent` arrays on
6
- // the raw .map JSON. Sourcemap sources whose `sourcesContent[i]` was
7
- // omitted are skipped.
4
+ // (flat Map<projectRelPath, content> from `.modules`); sourcemaps list
5
+ // their files as bundle-sourcemap.js reads them. Sourcemap sources whose
6
+ // `sourcesContent[i]` was omitted are skipped.
8
7
  //
9
8
  // What a stasis bundle records is not all source: `Bundle.formats`
10
9
  // marks each entry, and three of those formats are files only in the
@@ -28,6 +27,7 @@
28
27
  // a new bundle kind or field handled here is visible to both.
29
28
 
30
29
  import { Bundle } from '@exodus/stasis-core/bundle'
30
+ import { sourcemapEntries } from './bundle-sourcemap.js'
31
31
  import { utf8ByteLength } from './utf8.js'
32
32
 
33
33
  const sourcesCache = new WeakMap()
@@ -57,17 +57,35 @@ export function bundleSourcesAsMap(details) {
57
57
  result.set(file, content)
58
58
  }
59
59
  } else if (details.kind === 'sourcemap') {
60
- if (!details.json) return result
61
- const srcs = details.json.sources ?? []
62
- const contents = details.json.sourcesContent ?? []
63
- for (let i = 0; i < srcs.length; i++) {
64
- if (typeof contents[i] === 'string') result.set(srcs[i], contents[i])
60
+ for (const [path, content] of sourcemapEntries(details)) {
61
+ if (typeof content === 'string') result.set(path, content)
65
62
  }
66
63
  }
67
64
  if (key) sourcesCache.set(key, result)
68
65
  return result
69
66
  }
70
67
 
68
+ // A source's lines as the viewer numbers them: one more than its newlines.
69
+ export function bundleSourceLines(content) {
70
+ let lines = 1
71
+ for (let at = content.indexOf('\n'); at !== -1; at = content.indexOf('\n', at + 1)) lines++
72
+ return lines
73
+ }
74
+
75
+ // A bundle's sources in path order, and each one's number in it from 1: how a
76
+ // managed Code link names a file. The content hash that names a bundle fixes
77
+ // its sources, so the numbers stay put.
78
+ const sourceOrders = new WeakMap()
79
+ export function bundleSourceOrder(sources) {
80
+ let order = sourceOrders.get(sources)
81
+ if (!order) {
82
+ const paths = [...sources.keys()].toSorted()
83
+ order = { paths, numbers: new Map(paths.map((path, index) => [path, index + 1])) }
84
+ sourceOrders.set(sources, order)
85
+ }
86
+ return order
87
+ }
88
+
71
89
  // Every file the bundle carries, for a reader that wants the filesystem
72
90
  // rather than the source: what `bundleSourcesAsMap` returns, plus the
73
91
  // resources it leaves out, each in the form that is true to it.
@@ -97,6 +115,10 @@ export function bundleFilesAsMap(details) {
97
115
  const key = details?.bundle ?? details?.json
98
116
  if (key && filesCache.has(key)) return filesCache.get(key)
99
117
  const files = new Map(bundleSourcesAsMap(details))
118
+ // An npm package's files that are not text (view/npm-package.js) carry
119
+ // their size and digest alone: enough for Compare to tell a changed one
120
+ // from an unchanged one, as `{ format: 'digest', size, digest }`.
121
+ for (const [path, file] of details?.npmBinaries ?? []) files.set(path, { format: 'digest', ...file })
100
122
  if (details?.kind === 'stasis' && details.bundle) {
101
123
  const formats = details.bundle.formats
102
124
  for (const [file, content] of details.bundle.sources) {
@@ -139,6 +161,7 @@ function base64ByteLength(text) {
139
161
  // bundles' files by this, so its totals are the Overview's.
140
162
  export function bundleFileByteLength(content) {
141
163
  if (typeof content === 'string') return utf8ByteLength(content)
164
+ if (content?.format === 'digest') return content.size
142
165
  return content?.format === 'base64' ? base64ByteLength(content.data) : null
143
166
  }
144
167
 
@@ -163,7 +186,7 @@ export function bundleFileSizes(details) {
163
186
  if (key && sizesCache.has(key)) return sizesCache.get(key)
164
187
  const sizes = new Map()
165
188
  const files = bundleFilesAsMap(details)
166
- const paths = details?.kind === 'stasis' ? details.bundle?.sources.keys() : details?.json?.sources
189
+ const paths = details?.kind === 'stasis' ? details.bundle?.sources.keys() : details && sourcemapEntries(details).map(([path]) => path)
167
190
  for (const path of paths ?? []) sizes.set(path, bundleFileByteLength(files.get(path)))
168
191
  if (key) sizesCache.set(key, sizes)
169
192
  return sizes
@@ -202,22 +225,23 @@ export function bundleFileKinds(details) {
202
225
  const kinds = new Map()
203
226
  for (const [path, size] of sizes) {
204
227
  if (size === null && !unsized.has(path)) continue
205
- kinds.set(path, Bundle.isResourceFormat(formats?.get(path)) ? 'resource' : 'source')
228
+ // An npm package's file that is not text is a resource too: no source to view.
229
+ kinds.set(path, Bundle.isResourceFormat(formats?.get(path)) || details?.npmBinaries?.has(path) ? 'resource' : 'source')
206
230
  }
207
231
  kindsCache.set(sizes, kinds)
208
232
  return kinds
209
233
  }
210
234
 
211
- // `bundleFileSizes` narrowed to source: a resource's size is nulled, as a
212
- // directory capture's already is. The import graph draws this, and stays
213
- // source-only: an image or a font imports nothing and carries no finding.
235
+ // Source paths and their recorded byte sizes. Keep null for a source whose
236
+ // body is missing, but omit resources and directory captures entirely so
237
+ // graph consumers can distinguish unknown source bytes from non-source.
214
238
  export function bundleSourceSizes(details) {
215
239
  const sizes = bundleFileSizes(details)
216
240
  const formats = details?.kind === 'stasis' ? details.bundle?.formats : null
217
241
  if (!formats || formats.size === 0) return sizes
218
242
  if (sourceSizesCache.has(sizes)) return sourceSizesCache.get(sizes)
219
243
  const result = new Map()
220
- for (const [path, size] of sizes) result.set(path, Bundle.isResourceFormat(formats.get(path)) ? null : size)
244
+ for (const [path, size] of sizes) if (!Bundle.isResourceFormat(formats.get(path))) result.set(path, size)
221
245
  sourceSizesCache.set(sizes, result)
222
246
  return result
223
247
  }
@@ -264,8 +288,11 @@ export function bundlePackageDirs(details) {
264
288
  // Shared by the Advisories tab (a bulk registry lookup keyed on these
265
289
  // pairs) and the Compare slide (the dependency version-update diff).
266
290
  // Returns an empty Map for sourcemaps and v0 stasis bundles — neither
267
- // carries per-module version metadata.
268
- export function bundlePackageVersions(details, paths = null) {
291
+ // carries per-module version metadata. `keyOf(dir, info)` names each
292
+ // entry, the recorded name by default; Compare keys by install directory,
293
+ // as its sizes are, so an npm alias (`node_modules/alias` recording
294
+ // `actual-package`) joins its own sizes.
295
+ export function bundlePackageVersions(details, paths = null, keyOf = (_dir, info) => info.name) {
269
296
  const versions = new Map()
270
297
  if (details?.kind !== 'stasis' || !details.bundle?.modules) return versions
271
298
  const packageDirs = paths === null ? null : bundlePackageDirs(details)
@@ -274,8 +301,9 @@ export function bundlePackageVersions(details, paths = null) {
274
301
  if (selectedDirs && !selectedDirs.has(dir)) continue
275
302
  if (!dir.includes('node_modules')) continue
276
303
  if (!info?.name || typeof info.version !== 'string' || !info.version) continue
277
- let set = versions.get(info.name)
278
- if (!set) { set = new Set(); versions.set(info.name, set) }
304
+ const key = keyOf(dir, info)
305
+ let set = versions.get(key)
306
+ if (!set) { set = new Set(); versions.set(key, set) }
279
307
  set.add(info.version)
280
308
  }
281
309
  return versions
@@ -39,7 +39,19 @@ const FORMAT_TO_LANG = {
39
39
  'podfile-lock': 'yaml', xml: 'markup',
40
40
  }
41
41
 
42
- export function langForPath(path, format) {
42
+ // Whether a text is a JSON object by its ends: `{` first and `}` last,
43
+ // whitespace aside. Read from both ends, never the whole of a large file.
44
+ function objectText(content) {
45
+ if (typeof content !== 'string') return false
46
+ let end = content.length - 1, start = 0
47
+ while (start < end && /\s/u.test(content[start])) start++
48
+ while (end > start && /\s/u.test(content[end])) end--
49
+ return start < end && content[start] === '{' && content[end] === '}'
50
+ }
51
+
52
+ // `content`, where the caller has it, settles what the name alone cannot: a
53
+ // `.map` file is a source map, JSON, when its text is an object.
54
+ export function langForPath(path, format, content) {
43
55
  if (typeof format === 'string' && FORMAT_TO_LANG[format]) return FORMAT_TO_LANG[format]
44
56
  if (typeof path !== 'string') return null
45
57
  // Only the filename can contribute an extension. A dotted directory such
@@ -47,5 +59,7 @@ export function langForPath(path, format) {
47
59
  const basename = path.slice(path.lastIndexOf('/') + 1)
48
60
  const dot = basename.lastIndexOf('.')
49
61
  if (dot <= 0) return null
50
- return EXT_TO_LANG[basename.slice(dot + 1).toLowerCase()] ?? null
62
+ const ext = basename.slice(dot + 1).toLowerCase()
63
+ if (ext === 'map') return objectText(content) ? 'json' : null
64
+ return EXT_TO_LANG[ext] ?? null
51
65
  }
@@ -5,7 +5,7 @@ export function defaultScanModels(provider: string | null = null) {
5
5
  'anthropic/claude-opus-5.5',
6
6
  'anthropic/claude-fable-5.1',
7
7
  'anthropic/claude-sonnet-5.5',
8
- 'anthropic/claude-haiku-4.5',
8
+ 'anthropic/claude-haiku-5.5',
9
9
  'openai/gpt-6-astra',
10
10
  'openai/gpt-6-astra-pro',
11
11
  'openai/gpt-6.1-sol',
@@ -20,6 +20,7 @@ export function defaultScanModels(provider: string | null = null) {
20
20
  'qwen/qwen3.8-max',
21
21
  'deepseek/deepseek-v4.1-flash',
22
22
  'deepseek/deepseek-v4-pro',
23
+ 'mistralai/mistral-large-4-0',
23
24
  ]
24
25
  // OpenRouter routes all listed providers. Direct-provider credentials only
25
26
  // expose that provider's models; no selection leaves the catalogue intact.
@@ -0,0 +1,23 @@
1
+ export type ReviewVerdict = 'accept' | 'accept-with-nits' | 'request-changes' | 'reject'
2
+ export interface ReviewSummary {
3
+ coverage: 'full' | 'partial' | null
4
+ checkedFiles: number | null
5
+ totalFiles: number | null
6
+ verdictLimit: ReviewVerdict
7
+ }
8
+ export const REVIEW_VERDICTS: Record<ReviewVerdict, string>
9
+ export function isReviewableFile(before: unknown, after: unknown): boolean
10
+ export function summarizeReview(entries: { file: string; status: ReviewVerdict | null }[], files: string[] | null, nonTextFiles?: string[] | null): ReviewSummary
11
+ export function allowedReviewVerdicts(entries: { file: string; status: ReviewVerdict | null }[], limit?: ReviewVerdict): ReviewVerdict[]
12
+ interface ReviewNpmVersion { version: string; private?: boolean; manifest?: Record<string, unknown> }
13
+ export function npmTakenDown(entry: { npm: ReviewNpmVersion }): boolean
14
+ export function npmReviewVerdictLimit(npm: ReviewNpmVersion | null | undefined,
15
+ advisoryList?: { versions: string[]; advisories: { id?: string; title?: string; severity?: string; cwe?: string[]; affected: number[] }[] } | null,
16
+ socket?: { alerts: { type: string }[] } | null, risk?: { findings: { id: string }[] } | null): ReviewVerdict
17
+ export function needsPackageJsonReview(entries: { file: string; status: ReviewVerdict | null }[], files: string[], nonTextFiles?: string[] | null): boolean
18
+ export function parseReviewNotes(comment: string): { text: string; bullet: boolean }[]
19
+ export function auditMarkdown(review: ReviewSummary & {
20
+ package: string; version: string; integrity: string; basePackage: string | null; baseVersion: string | null
21
+ publishedAt: number | null; revokedAt: number | null; entries: { file: string; comment: string }[]
22
+ status: ReviewVerdict | null; comment: string
23
+ }): string | null