@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.
- package/LICENSE-APACHE +202 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +182 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +51 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +124 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +83 -0
- package/out/client-managed.js +169 -0
- package/out/client-sync.js +16 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +259 -63
- package/package.json +205 -57
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
- package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
- package/{server → server-e2e}/objstore/blob.ts +9 -2
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +58 -33
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +1 -1
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +53 -0
- package/server-managed/bundle-advisory-inventory.ts +75 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +54 -0
- package/server-managed/bundle-build.ts +168 -0
- package/server-managed/bundle-cache.ts +166 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +116 -0
- package/server-managed/bundle.ts +104 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +149 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1517 -0
- package/server-managed/db-neon.ts +277 -0
- package/server-managed/db-schema.ts +206 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +120 -0
- package/server-managed/github-app.ts +385 -0
- package/server-managed/github-errors.ts +49 -0
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +98 -0
- package/server-managed/github-oauth.ts +199 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2350 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +175 -0
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +86 -0
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +63 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +146 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +166 -0
- package/server-managed/repository-browser.ts +139 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +72 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +223 -0
- package/server-managed/storage-payload.ts +72 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +58 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-reports.ts +219 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +65 -0
- package/server-managed/workspace-shares.ts +162 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /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,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,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'])
|