@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (183) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +83 -0
  41. package/out/client-managed.js +169 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +103 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +63 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,10 @@
1
+ export const BUNDLE_METADATA_VERSION: number
2
+ export interface BundleIdentity { integrity: string; kind: string | null; size: number }
3
+ export interface BundleDetails extends BundleIdentity { bundle?: unknown; json?: unknown }
4
+ export function parseBundleContents(text: string, identity: BundleIdentity): BundleDetails
5
+ export interface BundleMetadata extends Record<string, unknown> {
6
+ files: [string, number | null, string | null, number | null][]
7
+ codeStats: { files: number; lines: number; bytes: number }
8
+ }
9
+ export function createBundleMetadata(details: BundleDetails): Promise<BundleMetadata>
10
+ export function createBundleSummary(details: BundleDetails, metadata?: BundleMetadata): { files: number; codeFiles: number; lines: number; commit?: string }
@@ -0,0 +1,182 @@
1
+ import { bundleCodeStats } from './bundle-stats.js'
2
+ import { bundleCommitHash } from './bundle-commit.js'
3
+ import { Bundle } from '@exodus/stasis-core/bundle'
4
+ import { computeFileHash } from '@preventive/report'
5
+ import { utf8ByteLength } from './utf8.js'
6
+ import { bundleFileSizes, bundleSourcesAsMap, bundleUnsizedFiles } from './bundle-sources.js'
7
+
8
+ // Version 2 sizes every file by its bytes, resources included. A version 1
9
+ // index sized by what the source map held when it was written: before the
10
+ // map asked each entry's format, that took a directory capture for a file
11
+ // and a base64 resource for its spelling; after, it left every resource
12
+ // out. Either way its sizes are not file sizes, so it is read for its
13
+ // hashes alone and rebuilt on open.
14
+ // Version 3 also retains the bundle's repository and package origin metadata.
15
+ export const BUNDLE_METADATA_VERSION = 3
16
+ const INDEX_VERSION = BUNDLE_METADATA_VERSION
17
+ const hashJobs = new WeakMap()
18
+ const SOURCE_TABS = new Set(['terminal', 'code', 'search', 'compare'])
19
+
20
+ export function bundleNeedsSources(tab, sourceFile = null) {
21
+ return Boolean(sourceFile) || SOURCE_TABS.has(tab)
22
+ }
23
+
24
+ // Bound outstanding WebCrypto jobs: serial awaits incur one task round-trip
25
+ // per file, while an unbounded Promise.all holds every encoded body at once.
26
+ export function computeBundleFileHashes(details) {
27
+ if (details.fileHashes) return Promise.resolve(details.fileHashes)
28
+ if (hashJobs.has(details)) return hashJobs.get(details)
29
+ const job = (async () => {
30
+ const entries = [...bundleSourcesAsMap(details)]
31
+ const result = new Map()
32
+ for (let i = 0; i < entries.length; i += 32) {
33
+ const hashes = await Promise.all(entries.slice(i, i + 32).map(async ([file, content]) => [file, await computeFileHash(content)]))
34
+ for (const [file, hash] of hashes) result.set(file, hash)
35
+ }
36
+ details.fileHashes = result
37
+ return result
38
+ })()
39
+ hashJobs.set(details, job)
40
+ job.catch(() => hashJobs.delete(details))
41
+ return job
42
+ }
43
+
44
+ // Count source lines without charging a trailing newline as an extra line.
45
+ // The source map intentionally excludes non-text resources, so a null entry
46
+ // remains the metadata marker for images, fonts, and other binary payloads.
47
+ export function bundleSourceLineCount(content) {
48
+ if (typeof content !== 'string' || content.length === 0) return 0
49
+ const breaks = content.match(/\r\n|\r|\n/gu)?.length ?? 0
50
+ return breaks + (/[\r\n]$/u.test(content) ? 0 : 1)
51
+ }
52
+
53
+ function mapObject(value) {
54
+ return value instanceof Map ? Object.fromEntries([...value].map(([key, v]) => [key, mapObject(v)])) : value
55
+ }
56
+
57
+ // A private, versioned derivative of the bundle, not a Stasis lock. Source
58
+ // bodies are omitted; file sizes and hashes keep metadata views self-contained.
59
+ // A row is `[path, bytes, hash, lines]`: a resource has bytes but no hash or
60
+ // lines, since it is no source; a directory capture has none of the three.
61
+ // `unsized` names the mounted files with no bytes to give (a base64 spelling
62
+ // that does not decode), which a null size alone cannot tell from no file.
63
+ export async function createBundleMetadata(details) {
64
+ const hashes = await computeBundleFileHashes(details)
65
+ const sizes = bundleFileSizes(details)
66
+ const sourceLines = details.lineCounts ??= new Map([...bundleSourcesAsMap(details)]
67
+ .map(([path, content]) => [path, bundleSourceLineCount(content)]))
68
+ const result = { version: INDEX_VERSION, integrity: details.integrity, kind: details.kind, size: details.size, codeStats: bundleCodeStats(sourceLines, sizes),
69
+ files: [...sizes].map(([path, size]) => [path, size, hashes.get(path) ?? null, sourceLines.get(path) ?? null]) }
70
+ const unsized = bundleUnsizedFiles(details)
71
+ if (unsized.size > 0) result.unsized = [...unsized]
72
+ if (details.kind === 'sourcemap') {
73
+ const { version, file, sourceRoot, names, sources = [], sourcesContent = [] } = details.json
74
+ result.json = { version, file, sourceRoot, sources }
75
+ // Sourcemaps can repeat a path with different/absent content. Keep the
76
+ // Overview's positional inventory, alongside the path-keyed graph index.
77
+ result.sourceSizes = sources.map((_, i) => typeof sourcesContent[i] === 'string' ? utf8ByteLength(sourcesContent[i]) : null)
78
+ result.namesCount = names?.length ?? null
79
+ } else {
80
+ const b = details.bundle
81
+ 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] }
82
+ if (b.version === 0) {
83
+ bundle.sources = Object.fromEntries([...sizes.keys()].map((path) => [path, null]))
84
+ } else {
85
+ const modules = {}, sources = {}
86
+ for (const [dir, info] of b.modules) {
87
+ const target = dir.split('/').includes('node_modules') ? modules : sources
88
+ Object.defineProperty(target, dir, { enumerable: true, value: {
89
+ name: info.name, version: info.version, ecosystem: info.ecosystem,
90
+ files: Object.fromEntries(Object.keys(info.files).map((path) => [path, null])),
91
+ } })
92
+ }
93
+ bundle.modules = modules
94
+ if (b.config.scope === 'full') { bundle.sources = sources; bundle.entries = [...b.entries] }
95
+ }
96
+ result.bundle = bundle
97
+ }
98
+ return result
99
+ }
100
+
101
+ // Catalogs need counts without the per-file inventory. Match scan inputs:
102
+ // textual resources count as files (with zero LoC); binary resources do not
103
+ // count as Code inputs, and missing source bodies/directories are not files.
104
+ export function createBundleSummary(details, metadata) {
105
+ const files = (metadata?.files ?? [...bundleFileSizes(details)]).filter(([, size]) => size !== null)
106
+ const formats = details.kind === 'stasis' ? details.bundle.formats : null
107
+ const lines = metadata?.codeStats.lines ?? [...bundleSourcesAsMap(details).values()].reduce((sum, source) => sum + bundleSourceLineCount(source), 0)
108
+ const commit = details.kind === 'stasis' ? bundleCommitHash(details.bundle.repo?.commit) : null
109
+ return { files: files.length, lines,
110
+ codeFiles: files.filter(([path]) => !['resource:base64', 'directory'].includes(formats?.get(path))).length,
111
+ ...(commit ? { commit } : {}) }
112
+ }
113
+
114
+ // `stale` marks an index this version did not write: its hashes still
115
+ // answer report lookups, but an open rebuilds it for current sizes, line
116
+ // counts and origin metadata.
117
+ export function parseBundleMetadata(data, integrity) {
118
+ if (![1, 2, INDEX_VERSION].includes(data?.version) || data.integrity !== integrity || !['stasis', 'sourcemap'].includes(data.kind)
119
+ || !Number.isSafeInteger(data.size) || data.size < 0 || !Array.isArray(data.files)) throw new Error('Invalid bundle metadata')
120
+ const stale = data.version !== INDEX_VERSION
121
+ const legacySizes = data.version === 1
122
+ const fileHashes = new Map(), fileSizes = new Map(), lineCounts = new Map()
123
+ for (const row of data.files) {
124
+ // Version 1 rows predate the line count, and gave every sized entry a hash.
125
+ if (!Array.isArray(row) || (row.length !== 4 && !(legacySizes && row.length === 3))) throw new Error('Invalid bundle metadata file')
126
+ const [path, size, hash, lines] = row
127
+ if (typeof path !== 'string' || fileSizes.has(path) || (size !== null && (!Number.isSafeInteger(size) || size < 0))
128
+ || (hash !== null && (typeof hash !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/u.test(hash)))
129
+ || (size === null && hash !== null) || (legacySizes && size !== null && hash === null)
130
+ || (lines !== undefined && lines !== null && (!Number.isSafeInteger(lines) || lines < 0))) throw new Error('Invalid bundle metadata file')
131
+ fileSizes.set(path, size)
132
+ if (hash !== null) fileHashes.set(path, hash)
133
+ if (lines !== undefined && lines !== null) lineCounts.set(path, lines)
134
+ }
135
+ // Only a sizeless row can name a mounted file with no size, and only once.
136
+ const unsized = new Set(data.unsized ?? [])
137
+ if ((data.unsized !== undefined && (legacySizes || !Array.isArray(data.unsized) || unsized.size !== data.unsized.length))
138
+ || [...unsized].some((path) => typeof path !== 'string' || !fileSizes.has(path) || fileSizes.get(path) !== null)) throw new Error('Invalid bundle metadata')
139
+ const details = { integrity, kind: data.kind, size: data.size, metadataOnly: true, fileSizes, fileHashes, lineCounts, unsizedFiles: unsized, stale }
140
+ if (data.kind === 'stasis') {
141
+ details.bundle = Bundle.parse(JSON.stringify(data.bundle))
142
+ const paths = details.bundle.sources
143
+ if (paths.size !== fileSizes.size || [...paths.keys()].some((path) => !fileSizes.has(path))) throw new Error('Invalid bundle metadata inventory')
144
+ // A file is hashed exactly when it is source: a resource has bytes and
145
+ // no hash. And only a base64 resource can be mounted without a size.
146
+ // Both are checked against the formats, which only the bundle carries.
147
+ const formats = details.bundle.formats
148
+ if (!legacySizes && [...fileSizes].some(([path, size]) => fileHashes.has(path) !== (size !== null && !Bundle.isResourceFormat(formats.get(path))))) {
149
+ throw new Error('Invalid bundle metadata inventory')
150
+ }
151
+ if ([...unsized].some((path) => formats.get(path) !== 'resource:base64')) throw new Error('Invalid bundle metadata inventory')
152
+ } else {
153
+ if (!data.json || typeof data.json !== 'object' || ![null, undefined].includes(data.namesCount) && (!Number.isSafeInteger(data.namesCount) || data.namesCount < 0)) throw new Error('Invalid sourcemap metadata')
154
+ if (!Array.isArray(data.json.sources) || data.json.sources.some((path) => !fileSizes.has(path))
155
+ || !Array.isArray(data.sourceSizes) || data.sourceSizes.length !== data.json.sources.length
156
+ || data.sourceSizes.some((size) => size !== null && (!Number.isSafeInteger(size) || size < 0))
157
+ || [...fileSizes].some(([path, size]) => fileHashes.has(path) !== (size !== null)) || unsized.size > 0) throw new Error('Invalid sourcemap inventory')
158
+ details.json = data.json
159
+ details.sourceSizes = data.sourceSizes
160
+ details.namesCount = data.namesCount
161
+ }
162
+ if (data.codeStats === undefined) details.codeStats = bundleCodeStats(lineCounts, fileSizes)
163
+ else {
164
+ const stats = data.codeStats
165
+ const validCount = value => Number.isSafeInteger(value) && value >= 0
166
+ if (!stats || ![stats.files, stats.lines, stats.bytes].every(validCount) || !Array.isArray(stats.languages)
167
+ || stats.languages.some(row => !row || typeof row.key !== 'string' || typeof row.label !== 'string'
168
+ || ![row.files, row.lines, row.bytes].every(validCount))) throw new Error('Invalid bundle code stats')
169
+ details.codeStats = stats
170
+ }
171
+ return details
172
+ }
173
+
174
+ // HTTP content encoding handles compression before this shared JSON parser.
175
+ export function parseBundleContents(text, { integrity, kind, size }) {
176
+ if (kind === 'stasis') return { integrity, kind, size, bundle: Bundle.parse(text) }
177
+ if (kind !== 'sourcemap') throw new Error('Unsupported bundle format')
178
+ const json = JSON.parse(text)
179
+ if (!Array.isArray(json?.sources) || json.sources.some(path => typeof path !== 'string')
180
+ || (json.sourcesContent !== undefined && (!Array.isArray(json.sourcesContent) || json.sourcesContent.some(content => content !== null && typeof content !== 'string')))) throw new Error('Invalid sourcemap')
181
+ return { integrity, kind, size, json }
182
+ }
@@ -0,0 +1,2 @@
1
+ import type { BundleDetails } from './bundle-metadata.js'
2
+ export function bundleReasons(details: BundleDetails, sourcePaths?: Iterable<string>): Map<string, Set<string>>
@@ -0,0 +1,21 @@
1
+ import { bundleFileSizes } from './bundle-sources.js'
2
+
3
+ // Reason metadata attributes files to consumers (run, build plugins, etc.).
4
+ // It is informational and can be absent on older/single-consumer bundles.
5
+ export function bundleReasons(details, sourcePaths) {
6
+ const reasons = new Map()
7
+ const raw = details?.kind === 'stasis' ? details.bundle?.reason : null
8
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return reasons
9
+ const paths = new Set(sourcePaths ?? [...bundleFileSizes(details)].filter(([, size]) => size != null).map(([path]) => path))
10
+ for (const [reason, files] of Object.entries(raw).toSorted(([a], [b]) => a.localeCompare(b))) {
11
+ if (!reason || !Array.isArray(files)) continue
12
+ const present = new Set(files.filter((file) => typeof file === 'string' && paths.has(file)))
13
+ if (present.size > 0) reasons.set(reason, present)
14
+ }
15
+ // A reason is only useful as a filter if it changes the set of files.
16
+ // Keep all named options when at least one differs, otherwise hide the
17
+ // selector (and discard any stale selection) in every visualization.
18
+ if (![...reasons.values()].some((files) => files.size < paths.size)) reasons.clear()
19
+ return reasons
20
+ }
21
+
@@ -0,0 +1,5 @@
1
+ import type { BundleDetails } from './bundle-metadata.js'
2
+
3
+ export function bundleSourceImports(details: BundleDetails, sources?: Map<string, string>): Map<string, Map<string, string | null>>
4
+ export function sourceLinkResolver(sources: Map<string, string>, parent: string, imports?: Map<string, Map<string, string | null>> | null): (specifier: string) => string | null
5
+ export function bundleSourceLinkResolver(details: BundleDetails, parent: string): (specifier: string) => string | null
@@ -0,0 +1,82 @@
1
+ import { bundleSourcesAsMap } from './bundle-sources.js'
2
+
3
+ const indexes = new WeakMap()
4
+
5
+ // Normalize only path segments. Keep sourcemap URL roots (webpack:/// etc.)
6
+ // and leading ../ segments, and never guess extensions or basename matches.
7
+ function normalizePath(path) {
8
+ const match = /^([a-z][a-z\d+.-]*:\/\/[^/]*)(.*)$/iu.exec(path)
9
+ const prefix = match?.[1] ?? ''
10
+ const rest = match?.[2] ?? path
11
+ const absolute = rest.startsWith('/')
12
+ const parts = []
13
+ for (const part of rest.split('/')) {
14
+ if (!part || part === '.') continue
15
+ if (part === '..' && parts.length > 0 && parts.at(-1) !== '..') parts.pop()
16
+ else if (part === '..' && absolute && parts.length === 0) return null
17
+ else parts.push(part)
18
+ }
19
+ return prefix + (absolute ? '/' : '') + parts.join('/')
20
+ }
21
+
22
+ function buildIndex(sources) {
23
+ const paths = new Map()
24
+ for (const path of sources.keys()) {
25
+ const normalized = normalizePath(path)
26
+ if (normalized === null) continue
27
+ // Distinct source keys with the same normalized path are ambiguous.
28
+ paths.set(normalized, paths.has(normalized) ? null : path)
29
+ }
30
+ return paths
31
+ }
32
+
33
+ // Merge conditions before linking. When exporting a restricted source set,
34
+ // include only visible parents and replace every unavailable target with null:
35
+ // its path must not leak, and relative fallback must still remain blocked.
36
+ export function bundleSourceImports(details, sources = bundleSourcesAsMap(details)) {
37
+ const imports = new Map()
38
+ if (details?.kind === 'stasis') {
39
+ for (const byParent of details.bundle?.imports?.values() ?? []) {
40
+ for (const [parent, specifiers] of byParent) {
41
+ if (!sources.has(parent)) continue
42
+ if (!imports.has(parent)) imports.set(parent, new Map())
43
+ const merged = imports.get(parent)
44
+ for (const [specifier, target] of specifiers) {
45
+ const resolved = typeof target === 'string' && sources.has(target) ? target : null
46
+ // A platform object, missing file, or disagreement under any
47
+ // condition permanently blocks this specifier, including fallback.
48
+ merged.set(specifier, merged.has(specifier) && merged.get(specifier) !== resolved ? null : resolved)
49
+ }
50
+ }
51
+ }
52
+ }
53
+ return imports
54
+ }
55
+
56
+ export function sourceLinkResolver(sources, parent, imports = null) {
57
+ let index = indexes.get(sources)
58
+ if (!index) { index = buildIndex(sources); indexes.set(sources, index) }
59
+ const recorded = imports?.get(parent)
60
+ const directory = parent.slice(0, parent.lastIndexOf('/') + 1)
61
+ return (specifier) => {
62
+ if (typeof specifier !== 'string' || /\p{Cc}/u.test(specifier)) return null
63
+ if (recorded?.has(specifier)) {
64
+ const target = recorded.get(specifier)
65
+ return typeof target === 'string' && sources.has(target) ? target : null
66
+ }
67
+ if (!specifier.startsWith('./') && !specifier.startsWith('../')) return null
68
+ const name = specifier.slice(specifier.lastIndexOf('/') + 1)
69
+ const dot = name.lastIndexOf('.')
70
+ if (dot <= 0 || dot === name.length - 1) return null
71
+ return index.get(normalizePath(directory + specifier)) ?? null
72
+ }
73
+ }
74
+
75
+ const bundleImports = new WeakMap()
76
+
77
+ export function bundleSourceLinkResolver(details, parent) {
78
+ const sources = bundleSourcesAsMap(details)
79
+ let imports = bundleImports.get(sources)
80
+ if (!imports) { imports = bundleSourceImports(details, sources); bundleImports.set(sources, imports) }
81
+ return sourceLinkResolver(sources, parent, imports)
82
+ }
@@ -0,0 +1,3 @@
1
+ import type { BundleDetails } from './bundle-metadata.js'
2
+ export function bundleSourcesAsMap(details: BundleDetails): Map<string, string>
3
+ export function bundlePackageVersions(details: BundleDetails, paths?: Iterable<string> | null): Map<string, Set<string>>
@@ -0,0 +1,284 @@
1
+ // Shared helper: given a parsed bundle `details`, return a
2
+ // Map<file, content-string> of its source files. Stasis bundles
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.
8
+ //
9
+ // What a stasis bundle records is not all source: `Bundle.formats`
10
+ // marks each entry, and three of those formats are files only in the
11
+ // sense that they occupy a path. `Bundle.sources` hands back the raw
12
+ // stored content for every one of them, so the format is what has to
13
+ // be asked — the content's JS type cannot tell them apart:
14
+ //
15
+ // directory a readdir capture, content `["a.js","b.js"]`
16
+ // resource:base64 an image or font, content its base64 text
17
+ // resource raw bytes, the one case not a string already
18
+ //
19
+ // A directory capture is the one that does real damage, because it
20
+ // claims a path a directory also occupies: hand it to the terminal
21
+ // and `ls` lists the name twice and stops listing what is under it.
22
+ // The base64 resources are quieter but equally wrong — a PNG read as
23
+ // a wall of text, counted toward the language shares that
24
+ // render-bundle.js says resources are kept out of.
25
+ //
26
+ // Own module so `render-bundle.js` (Code tab, finding tree, graph
27
+ // data) and `terminal-attach.js` (in-shell FS) read one definition —
28
+ // a new bundle kind or field handled here is visible to both.
29
+
30
+ import { Bundle } from '@exodus/stasis-core/bundle'
31
+ import { utf8ByteLength } from './utf8.js'
32
+
33
+ const sourcesCache = new WeakMap()
34
+ const filesCache = new WeakMap()
35
+ const sizesCache = new WeakMap()
36
+ const sourceSizesCache = new WeakMap()
37
+ const kindsCache = new WeakMap()
38
+ const unsizedCache = new WeakMap()
39
+
40
+ export function bundleSourcesAsMap(details) {
41
+ if (details?.metadataOnly) return new Map()
42
+ const key = details?.bundle ?? details?.json
43
+ if (key && sourcesCache.has(key)) return sourcesCache.get(key)
44
+ const result = new Map()
45
+ if (!details) return result
46
+ if (details.kind === 'stasis') {
47
+ if (!details.bundle) return result
48
+ const formats = details.bundle.formats
49
+ for (const [file, content] of details.bundle.sources) {
50
+ if (typeof content !== 'string') continue
51
+ // `isResourceFormat` is the package's own list, so a format it
52
+ // adds later is excluded here without this needing to know. A v0
53
+ // bundle records no formats at all: `get` returns undefined,
54
+ // which is not a resource format, and every entry is kept as
55
+ // before.
56
+ if (Bundle.isResourceFormat(formats?.get(file))) continue
57
+ result.set(file, content)
58
+ }
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])
65
+ }
66
+ }
67
+ if (key) sourcesCache.set(key, result)
68
+ return result
69
+ }
70
+
71
+ // Every file the bundle carries, for a reader that wants the filesystem
72
+ // rather than the source: what `bundleSourcesAsMap` returns, plus the
73
+ // resources it leaves out, each in the form that is true to it.
74
+ //
75
+ // The two resource formats are not two encodings of the same thing.
76
+ // Stasis picks between them by whether the bytes are valid UTF-8:
77
+ // `resource` is that text, stored verbatim, so it stays a string and a
78
+ // reader sees the SVG or the licence file it is. `resource:base64` is
79
+ // the other case — bytes no string spells — and it is handed over
80
+ // still spelt that way, as `{ format: 'base64', data }`. The terminal
81
+ // takes that form since 2.0 and decodes it the first time the file is
82
+ // read, so a bundle carrying a hundred images costs a hundred strings
83
+ // rather than a hundred decoded buffers, and pays for the one that is
84
+ // opened. Either way it knows what it is holding: `ls -l` and `wc -c`
85
+ // report the real byte count rather than the length of the base64, and
86
+ // `cat` declines instead of printing mojibake.
87
+ //
88
+ // Decoding there also puts the diagnostic where the file is named: a
89
+ // spelling that does not decode is reported by the command that read
90
+ // it, under the detail `base64 source`. Decoding here could only drop
91
+ // the file and say nothing.
92
+ //
93
+ // Directory captures stay out. They are the one entry that is not a
94
+ // file in any encoding, and mounting one puts a second `lib` beside
95
+ // the directory it names.
96
+ export function bundleFilesAsMap(details) {
97
+ const key = details?.bundle ?? details?.json
98
+ if (key && filesCache.has(key)) return filesCache.get(key)
99
+ const files = new Map(bundleSourcesAsMap(details))
100
+ if (details?.kind === 'stasis' && details.bundle) {
101
+ const formats = details.bundle.formats
102
+ for (const [file, content] of details.bundle.sources) {
103
+ if (typeof content !== 'string') continue
104
+ const format = formats?.get(file)
105
+ if (format === 'resource') files.set(file, content)
106
+ else if (format === 'resource:base64') files.set(file, { format: 'base64', data: content })
107
+ }
108
+ }
109
+ if (key) filesCache.set(key, files)
110
+ return files
111
+ }
112
+
113
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
114
+
115
+ // The length of the bytes a base64 spelling decodes to — three for every
116
+ // four characters, less what the padding stands for — or null when it
117
+ // decodes to none. The terminal decodes strictly (`@exodus/bytes`): RFC
118
+ // 4648's alphabet, no whitespace, padding present or left off but never
119
+ // wrong, and no stray bits in the last character. A spelling it refuses is
120
+ // a file `wc -c` cannot read, so it gets no size here rather than an
121
+ // invented one. Checked without decoding: a bundle's images are not worth
122
+ // a buffer each just to be weighed.
123
+ function base64ByteLength(text) {
124
+ const padded = text.endsWith('=')
125
+ if (padded ? text.length % 4 !== 0 || text.at(-3) === '=' : text.length % 4 === 1) return null
126
+ const data = padded ? text.slice(0, text.endsWith('==') ? -2 : -1) : text
127
+ if (/[^A-Za-z0-9+/]/u.test(data)) return null
128
+ // A trailing group of 2 or 3 characters spells 1 or 2 bytes; the bits of
129
+ // its last character past those bytes must be zero.
130
+ const tail = data.length % 4
131
+ if (tail > 1 && BASE64_ALPHABET.indexOf(data.at(-1)) & (tail === 2 ? 0x0f : 0x03)) return null
132
+ return Math.floor(data.length * 3 / 4)
133
+ }
134
+
135
+ // The byte size of one entry of `bundleFilesAsMap`, as `wc -c` reports it
136
+ // in the terminal: text weighs the UTF-8 it encodes to, and a base64
137
+ // declaration the bytes it decodes to — null when it decodes to none.
138
+ // Anything else is no file, and null too. The Compare tab sizes both
139
+ // bundles' files by this, so its totals are the Overview's.
140
+ export function bundleFileByteLength(content) {
141
+ if (typeof content === 'string') return utf8ByteLength(content)
142
+ return content?.format === 'base64' ? base64ByteLength(content.data) : null
143
+ }
144
+
145
+ // Every path the bundle records, keyed to the byte size of the file it
146
+ // holds — the size `wc -c` reports for it in the terminal, which is to
147
+ // say the size of the entry in `bundleFilesAsMap`: a source or `resource`
148
+ // weighs the UTF-8 its text encodes to, a `resource:base64` the bytes its
149
+ // base64 decodes to. Null is a path with no size to give: a directory
150
+ // capture, which is no file; a sourcemap source whose body was left out;
151
+ // or a `resource:base64` whose spelling does not decode.
152
+ //
153
+ // The Overview and the Treemap weigh a bundle by this, so what they show
154
+ // adds up to what `du` does. Neither the source-only view nor the base64
155
+ // text will do: the first drops every image and font, the second
156
+ // inflates them by a third.
157
+ //
158
+ // Metadata views need byte sizes and paths, never the bodies. The
159
+ // persistent index supplies this map directly; full parses compute it once.
160
+ export function bundleFileSizes(details) {
161
+ if (details?.fileSizes) return details.fileSizes
162
+ const key = details?.bundle ?? details?.json
163
+ if (key && sizesCache.has(key)) return sizesCache.get(key)
164
+ const sizes = new Map()
165
+ const files = bundleFilesAsMap(details)
166
+ const paths = details?.kind === 'stasis' ? details.bundle?.sources.keys() : details?.json?.sources
167
+ for (const path of paths ?? []) sizes.set(path, bundleFileByteLength(files.get(path)))
168
+ if (key) sizesCache.set(key, sizes)
169
+ return sizes
170
+ }
171
+
172
+ // The files the terminal mounts that have no size: a `resource:base64`
173
+ // whose spelling does not decode. The terminal lists such a file, and says
174
+ // why it cannot read it when asked, so the views list it too — but a null
175
+ // size alone cannot tell it from an entry that is no file at all, so it is
176
+ // named here. A metadata-only open reads the set from the index, which is
177
+ // the one thing a size and a format cannot say about a path.
178
+ export function bundleUnsizedFiles(details) {
179
+ if (details?.metadataOnly) return details.unsizedFiles ?? new Set()
180
+ const sizes = bundleFileSizes(details)
181
+ if (unsizedCache.has(sizes)) return unsizedCache.get(sizes)
182
+ const unsized = new Set()
183
+ for (const path of bundleFilesAsMap(details).keys()) if (sizes.get(path) === null) unsized.add(path)
184
+ unsizedCache.set(sizes, unsized)
185
+ return unsized
186
+ }
187
+
188
+ // What each file the terminal mounts is, for a view that lists files:
189
+ // 'source', or 'resource' for an image, font or other asset — a file, sized
190
+ // like any other, but none the source viewer can show. Its keys are
191
+ // `bundleFilesAsMap`'s, and nothing else: a directory capture, or an entry
192
+ // with no body to mount (a sourcemap source whose `sourcesContent` was left
193
+ // out, a body that is no string), is no file and is absent. It reads sizes
194
+ // and formats, not bodies, so it answers for a metadata-only open as it
195
+ // does for a parsed one: a sized path is a mounted file, and so is one
196
+ // `bundleUnsizedFiles` names.
197
+ export function bundleFileKinds(details) {
198
+ const sizes = bundleFileSizes(details)
199
+ if (kindsCache.has(sizes)) return kindsCache.get(sizes)
200
+ const unsized = bundleUnsizedFiles(details)
201
+ const formats = details?.kind === 'stasis' ? details.bundle?.formats : null
202
+ const kinds = new Map()
203
+ for (const [path, size] of sizes) {
204
+ if (size === null && !unsized.has(path)) continue
205
+ kinds.set(path, Bundle.isResourceFormat(formats?.get(path)) ? 'resource' : 'source')
206
+ }
207
+ kindsCache.set(sizes, kinds)
208
+ return kinds
209
+ }
210
+
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.
214
+ export function bundleSourceSizes(details) {
215
+ const sizes = bundleFileSizes(details)
216
+ const formats = details?.kind === 'stasis' ? details.bundle?.formats : null
217
+ if (!formats || formats.size === 0) return sizes
218
+ if (sourceSizesCache.has(sizes)) return sourceSizesCache.get(sizes)
219
+ const result = new Map()
220
+ for (const [path, size] of sizes) result.set(path, Bundle.isResourceFormat(formats.get(path)) ? null : size)
221
+ sourceSizesCache.set(sizes, result)
222
+ return result
223
+ }
224
+
225
+ // Map each stasis bundle source path to the package directory that
226
+ // owns it. A stasis `Bundle` already records authoritative package
227
+ // boundaries in `.modules` (a `Map<dir, { name, version, files }>`
228
+ // covering both `node_modules` deps and workspace packages — the
229
+ // PHP `vendor/<vendor>/<pkg>` case, monorepo workspaces, etc.); this
230
+ // mirrors the `Bundle.sources` getter's path construction (`dir/rel`,
231
+ // or just `rel` for the `.` root) so the returned keys line up exactly
232
+ // with `bundleSourcesAsMap`'s.
233
+ //
234
+ // The package views (overview, treemap, graph) otherwise classify
235
+ // paths with `bundlePkgOf`'s string heuristic, which only knows
236
+ // `node_modules/`/`dependencies/` and buckets everything else by
237
+ // top-level dir — collapsing sibling workspace packages under their
238
+ // shared parent (`vendor/aws/aws-crt-php` + `vendor/aws/aws-sdk-php`
239
+ // both fall under `vendor`). Feeding each path's dir into
240
+ // `bundlePkgOf` (`packageDir` option) keeps those packages separate.
241
+ //
242
+ // Returns null for sourcemap bundles (and anything without parsed
243
+ // modules) — they carry no package metadata, so callers fall back to
244
+ // the path heuristic alone.
245
+ export function bundlePackageDirs(details) {
246
+ if (details?.kind !== 'stasis' || !details.bundle?.modules) return null
247
+ const map = new Map()
248
+ for (const [dir, info] of details.bundle.modules) {
249
+ for (const rel of Object.keys(info.files)) {
250
+ map.set(dir === '.' ? rel : `${dir}/${rel}`, dir)
251
+ }
252
+ }
253
+ return map
254
+ }
255
+
256
+ // Map each named npm dependency a (stasis) bundle carries to the set of
257
+ // concrete versions present for it: `Map<name, Set<version>>`. Only
258
+ // `node_modules/...` modules that carry both a name and a non-empty
259
+ // version string count — stasis v1 `scope: 'full'` bundles merge
260
+ // workspace / own-source entries into the same `Bundle.modules` Map
261
+ // (often `version: '0.0.0'` or null), and those aren't upstream
262
+ // dependencies, so they're filtered out by the directory key. A package
263
+ // can map to more than one version (pnpm keeps duplicate majors side by
264
+ // side), hence the Set.
265
+ //
266
+ // Shared by the Advisories tab (a bulk registry lookup keyed on these
267
+ // pairs) and the Compare slide (the dependency version-update diff).
268
+ // Returns an empty Map for sourcemaps and v0 stasis bundles — neither
269
+ // carries per-module version metadata.
270
+ export function bundlePackageVersions(details, paths = null) {
271
+ const versions = new Map()
272
+ if (details?.kind !== 'stasis' || !details.bundle?.modules) return versions
273
+ const packageDirs = paths === null ? null : bundlePackageDirs(details)
274
+ const selectedDirs = paths === null ? null : new Set([...paths].map(path => packageDirs?.get(path)))
275
+ for (const [dir, info] of details.bundle.modules) {
276
+ if (selectedDirs && !selectedDirs.has(dir)) continue
277
+ if (!dir.includes('node_modules')) continue
278
+ if (!info?.name || typeof info.version !== 'string' || !info.version) continue
279
+ let set = versions.get(info.name)
280
+ if (!set) { set = new Set(); versions.set(info.name, set) }
281
+ set.add(info.version)
282
+ }
283
+ return versions
284
+ }
@@ -0,0 +1,41 @@
1
+ import { langForPath } from './code-language.js'
2
+
3
+ const BUNDLE_LANGUAGE_LABELS = Object.freeze({
4
+ javascript: 'JavaScript', jsx: 'JSX', typescript: 'TypeScript', tsx: 'TSX',
5
+ json: 'JSON', css: 'CSS', markup: 'HTML', yaml: 'YAML', bash: 'Shell',
6
+ markdown: 'Markdown', solidity: 'Solidity', php: 'PHP', rust: 'Rust',
7
+ ruby: 'Ruby', java: 'Java', cpp: 'C++', c: 'C', objectivec: 'Objective-C',
8
+ python: 'Python', go: 'Go', kotlin: 'Kotlin', swift: 'Swift', dart: 'Dart',
9
+ sql: 'SQL', lua: 'Lua', csharp: 'C#', scala: 'Scala', vue: 'Vue', svelte: 'Svelte',
10
+ })
11
+ const BUNDLE_EXTENSION_LANGUAGES = Object.freeze({
12
+ py: 'python', pyw: 'python', go: 'go', kt: 'kotlin', kts: 'kotlin', swift: 'swift',
13
+ dart: 'dart', sql: 'sql', lua: 'lua', cs: 'csharp', scala: 'scala', sc: 'scala',
14
+ vue: 'vue', svelte: 'svelte',
15
+ })
16
+
17
+ function bundleLanguageOf(path) {
18
+ const basename = typeof path === 'string' ? path.slice(path.lastIndexOf('/') + 1) : ''
19
+ const dot = basename.lastIndexOf('.')
20
+ const ext = dot > 0 ? basename.slice(dot + 1).trim().toLowerCase() : ''
21
+ const lang = langForPath(path) ?? BUNDLE_EXTENSION_LANGUAGES[ext]
22
+ if (lang) return { key: lang, label: BUNDLE_LANGUAGE_LABELS[lang] ?? lang }
23
+ return ext ? { key: `extension:${ext}`, label: `.${ext}` } : { key: 'other', label: 'Other' }
24
+ }
25
+
26
+ export function bundleCodeStats(lineCounts, fileSizes) {
27
+ const languages = new Map()
28
+ let bytes = 0, lines = 0
29
+ for (const [path, count] of lineCounts) {
30
+ const language = bundleLanguageOf(path)
31
+ const stat = languages.get(language.key) ?? { ...language, files: 0, lines: 0, bytes: 0 }
32
+ stat.files++
33
+ stat.lines += count
34
+ stat.bytes += fileSizes.get(path) ?? 0
35
+ languages.set(language.key, stat)
36
+ lines += count
37
+ bytes += fileSizes.get(path) ?? 0
38
+ }
39
+ return { files: lineCounts.size, lines, bytes, languages: [...languages.values()]
40
+ .toSorted((a, b) => b.lines - a.lines || a.label.localeCompare(b.label)) }
41
+ }
@@ -0,0 +1 @@
1
+ export const BUNDLE_TABS = new Set(['overview', 'graph', 'treemap', 'compare', 'advisories', 'issues', 'code', 'search', 'terminal'])