@exodus/stasis 1.0.0-alpha.2 → 1.0.0-beta.0
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/README.md +67 -3
- package/bin/stasis.js +322 -17
- package/logo.svg +12 -3
- package/package.json +39 -18
- package/src/audit.js +112 -0
- package/src/bundle-from-lockfile.js +65 -0
- package/src/bundle.js +4 -217
- package/src/cmd/build.js +321 -0
- package/src/cmd/bundle.js +950 -59
- package/src/cmd/diff.js +36 -0
- package/src/cmd/extract.js +193 -0
- package/src/cmd/sbom.js +35 -0
- package/src/diff.js +333 -0
- package/src/esbuild.js +5 -56
- package/src/loader-mock.js +19 -0
- package/src/loader.js +6 -132
- package/src/loaders/bash.js +257 -0
- package/src/loaders/php.js +1058 -0
- package/src/loaders/rust.js +336 -0
- package/src/loaders/solidity.js +70 -49
- package/src/lockfile.js +4 -80
- package/src/metro-transformer.js +6 -0
- package/src/metro.js +5 -0
- package/src/mock.js +310 -0
- package/src/parse.js +53 -0
- package/src/prune.js +4 -162
- package/src/sbom.js +266 -0
- package/src/scan.js +373 -0
- package/src/webpack.js +5 -48
- package/src/config.js +0 -205
- package/src/state.js +0 -440
- package/src/state.util.js +0 -25
- package/src/util.js +0 -60
package/src/cmd/diff.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
4
|
+
import { parseFileWithKind } from '../parse.js'
|
|
5
|
+
import { diffArtifacts, formatDiffStat, hasDifferences } from '../diff.js'
|
|
6
|
+
|
|
7
|
+
// Run the `stasis diff` CLI command end-to-end: read the two given
|
|
8
|
+
// lockfiles/bundles from disk (this is the layer that pulls in brotli, via
|
|
9
|
+
// `parseFileWithKind`), diff them through the file-free `@exodus/stasis/diff`
|
|
10
|
+
// API, and print the requested view. `left` is the baseline ("from"), `right`
|
|
11
|
+
// the comparison ("to"). Today only the `--stat` summary is implemented, so
|
|
12
|
+
// `stat` is required; the report goes to `out` (stdout by default). With
|
|
13
|
+
// `imports: true` (the `--imports` flag) the report also diffs the resolution
|
|
14
|
+
// graphs — which import edges were added, removed, or redirected.
|
|
15
|
+
//
|
|
16
|
+
// This layer owns the canonical hash: the file-free diff API doesn't pull in
|
|
17
|
+
// `node:crypto`, so we inject `sha512integrity` (the same digest `stasis run`
|
|
18
|
+
// records) for it to re-hash bundle bytes with.
|
|
19
|
+
//
|
|
20
|
+
// Either side may be a lockfile or a bundle, in any combination: a bundle's
|
|
21
|
+
// recorded bytes are re-hashed into the SRI digests a lockfile records, so a
|
|
22
|
+
// lockfile and a bundle compare in the same digest space (see src/diff.js).
|
|
23
|
+
//
|
|
24
|
+
// Returns `{ diff, differences }` so the caller can set the process exit code
|
|
25
|
+
// (non-zero when the artifacts differ, like `diff(1)`).
|
|
26
|
+
export function diffCommand({ cwd = process.cwd(), left, right, stat, imports = false, out = process.stdout } = {}) {
|
|
27
|
+
if (!left || !right) throw new Error('diff: two files (lockfile or bundle) are required')
|
|
28
|
+
if (!stat) throw new Error('diff: only --stat is implemented; pass stat: true')
|
|
29
|
+
|
|
30
|
+
const a = parseFileWithKind(resolve(cwd, left))
|
|
31
|
+
const b = parseFileWithKind(resolve(cwd, right))
|
|
32
|
+
const diff = diffArtifacts(a, b, { imports, hash: sha512integrity })
|
|
33
|
+
|
|
34
|
+
out.write(formatDiffStat(diff, { left, right, leftKind: a.kind, rightKind: b.kind }))
|
|
35
|
+
return { diff, differences: hasDifferences(diff) }
|
|
36
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { brotliDecompressSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
6
|
+
import { Lockfile } from '@exodus/stasis-core/lockfile'
|
|
7
|
+
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
8
|
+
import { moduleFileKey } from '@exodus/stasis-core/util'
|
|
9
|
+
|
|
10
|
+
const FILE_LOCK = 'stasis.lock.json'
|
|
11
|
+
|
|
12
|
+
// Re-derive a Lockfile from an already-parsed Bundle. A bundle records each file's
|
|
13
|
+
// content (UTF-8 source for code / 'resource', base64 for 'resource:base64'); a
|
|
14
|
+
// lockfile records each file's SRI digest -- always taken over the same UTF-8
|
|
15
|
+
// bytes the bundle stored (since the lockfile hashes the lockfile-visible content
|
|
16
|
+
// without decoding). Both
|
|
17
|
+
// share the same per-package bucket shape (name/version/files) plus the
|
|
18
|
+
// entries/config metadata, so the conversion is just "hash each file's bytes"
|
|
19
|
+
// while carrying everything else across verbatim.
|
|
20
|
+
//
|
|
21
|
+
// Requires a v1 bundle: v0 records no package name/version (Bundle.parse infers
|
|
22
|
+
// buckets with null metadata), and Lockfile.serialize must attest both for
|
|
23
|
+
// every node_modules bucket.
|
|
24
|
+
//
|
|
25
|
+
// The digest is taken over the same UTF-8 bytes we write to disk, so it equals
|
|
26
|
+
// what `stasis run --lock=add` would record for the extracted tree and what
|
|
27
|
+
// `stasis prune` recomputes when validating it.
|
|
28
|
+
// Reject a 'resource:base64' content string that isn't canonical base64. Buffer.from
|
|
29
|
+
// silently drops invalid characters and accepts non-canonical padding, so a tampered
|
|
30
|
+
// bundle could ship `XXXX_invalid_chars==` and have the decoded bytes hash to whatever
|
|
31
|
+
// it wanted -- the result would still self-consistently round-trip through `extract`
|
|
32
|
+
// (decode → write → re-hash → match), but the on-disk file would diverge from what
|
|
33
|
+
// any honest producer would have written. Fail closed at the schema-boundary by
|
|
34
|
+
// requiring decode → re-encode to be a fixed point.
|
|
35
|
+
function assertCanonicalBase64(content, file) {
|
|
36
|
+
const buf = Buffer.from(content, 'base64')
|
|
37
|
+
if (buf.toString('base64') !== content) {
|
|
38
|
+
throw new Error(`extract: non-canonical base64 in resource:base64 content for ${file}`)
|
|
39
|
+
}
|
|
40
|
+
return buf
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function lockfileFromBundle(bundle) {
|
|
44
|
+
const modules = new Map()
|
|
45
|
+
for (const [dir, { name, version, ecosystem, files }] of bundle.modules) {
|
|
46
|
+
const hashed = Object.create(null)
|
|
47
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
48
|
+
// The lockfile records sha512 of each file's raw on-disk bytes -- the same
|
|
49
|
+
// hash `stasis run` produces. The bundle stores 'resource:base64' files as
|
|
50
|
+
// base64 text, so we decode back to the raw bytes before hashing; code and
|
|
51
|
+
// 'resource' files are stored as raw UTF-8 and hash identically either way
|
|
52
|
+
// (Node hashes a string as its UTF-8 bytes). assertCanonicalBase64 closes
|
|
53
|
+
// the lying-tag hole: a non-canonical base64 string is rejected outright.
|
|
54
|
+
const file = dir === '.' ? rel : `${dir}/${rel}`
|
|
55
|
+
const bytes = bundle.formats.get(file) === 'resource:base64'
|
|
56
|
+
? assertCanonicalBase64(content, file)
|
|
57
|
+
: content
|
|
58
|
+
hashed[rel] = sha512integrity(bytes)
|
|
59
|
+
}
|
|
60
|
+
// Carry the dependency `ecosystem` across so the derived lockfile records the
|
|
61
|
+
// same ecosystem tag the bundle did.
|
|
62
|
+
modules.set(dir, ecosystem === undefined
|
|
63
|
+
? { name, version, files: hashed }
|
|
64
|
+
: { name, version, ecosystem, files: hashed })
|
|
65
|
+
}
|
|
66
|
+
// Carry the bundle's resolution + format maps across so the derived lockfile
|
|
67
|
+
// attests them too (Bundle.parse already validated the paths) -- without them
|
|
68
|
+
// the lockfile would be a legacy bytes-only one, and a later frozen run
|
|
69
|
+
// against the same bundle would skip the resolution/format cross-checks.
|
|
70
|
+
return new Lockfile({
|
|
71
|
+
config: bundle.config,
|
|
72
|
+
entries: bundle.entries,
|
|
73
|
+
modules,
|
|
74
|
+
imports: bundle.imports,
|
|
75
|
+
formats: bundle.formats,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Extract a brotli-compressed stasis code bundle (`stasis.code.br`) back onto
|
|
80
|
+
// disk: write every bundled source to `output` (default: cwd), preserving its
|
|
81
|
+
// project-relative path, and drop a matching `stasis.lock.json` alongside them.
|
|
82
|
+
// The lockfile is derived from the bundle's own contents, so the extracted tree
|
|
83
|
+
// validates against it out of the box (e.g. `stasis prune`). For legacy v0
|
|
84
|
+
// bundles only the sources are extracted -- see the in-body comment.
|
|
85
|
+
//
|
|
86
|
+
// Returns `{ dir, files }` where `files` is the number of sources written
|
|
87
|
+
// (the lockfile is not counted).
|
|
88
|
+
export function extractCommand({ cwd = process.cwd(), bundleFile, output } = {}) {
|
|
89
|
+
if (!bundleFile) throw new Error('extract: a bundle file is required')
|
|
90
|
+
const bundleAbs = resolve(cwd, bundleFile)
|
|
91
|
+
if (!existsSync(bundleAbs)) throw new Error(`extract: bundle file not found: ${bundleAbs}`)
|
|
92
|
+
|
|
93
|
+
let bundle
|
|
94
|
+
try {
|
|
95
|
+
bundle = Bundle.parse(brotliDecompressSync(readFileSync(bundleAbs)).toString('utf-8'))
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
// Bundle.parse rejects malformed shapes (e.g. a lockfile JSON fed by mistake,
|
|
98
|
+
// or a brotli blob that isn't a stasis bundle) with bare message-less asserts;
|
|
99
|
+
// wrap so the user sees which file failed instead of an empty error.
|
|
100
|
+
throw new Error(`extract: not a valid stasis bundle: ${bundleAbs}`, { cause })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// v0 legacy bundles record no package name/version (Bundle.parse infers buckets
|
|
104
|
+
// with null metadata), so no lockfile can be restored from them:
|
|
105
|
+
// Lockfile.serialize requires both for every node_modules bucket, and a
|
|
106
|
+
// null-metadata workspace entry would be rejected later by State.addFile's
|
|
107
|
+
// name/version cross-check. The sources themselves are still fully
|
|
108
|
+
// recoverable -- extract them and skip the lockfile, with a warning.
|
|
109
|
+
const withLockfile = bundle.version === Bundle.VERSION
|
|
110
|
+
|
|
111
|
+
const outDir = resolve(cwd, output ?? '.')
|
|
112
|
+
const outPrefix = outDir === sep ? outDir : `${outDir}${sep}`
|
|
113
|
+
const lockAbs = join(outDir, FILE_LOCK)
|
|
114
|
+
|
|
115
|
+
// Plan first, write later: validate every target path and confirm it stays
|
|
116
|
+
// inside outDir before touching disk, so a malformed bundle can't write a
|
|
117
|
+
// partial tree. Walk bundle.modules directly rather than the flattening
|
|
118
|
+
// `sources` getter, which silently last-wins when two buckets collapse to
|
|
119
|
+
// the same path. Treat the bundle as untrusted input and verify containment
|
|
120
|
+
// two ways: the resolved path must be BOTH non-escaping relative to outDir
|
|
121
|
+
// (no leading '..', not absolute -- `relative` returns an absolute path
|
|
122
|
+
// across Windows drives) AND lexically under `outDir/` (the trailing
|
|
123
|
+
// separator rejects sibling dirs that merely share outDir as a name prefix,
|
|
124
|
+
// e.g. `<outDir>-evil`). These checks are lexical only: pre-existing
|
|
125
|
+
// symlinks inside outDir are followed by the writes, so untrusted bundles
|
|
126
|
+
// belong in a fresh directory (see doc/extract.md).
|
|
127
|
+
const writes = []
|
|
128
|
+
const targets = new Set()
|
|
129
|
+
for (const [dir, { files }] of bundle.modules) {
|
|
130
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
131
|
+
// A `directory` capture (`stasis run --fs`) keys a listing at the directory's
|
|
132
|
+
// OWN path; it isn't a file to write (its child files recreate the directory),
|
|
133
|
+
// and writing one would collide with those children at the file-vs-directory
|
|
134
|
+
// guard below. Skip it on disk -- the derived lockfile still attests its hash
|
|
135
|
+
// (lockfileFromBundle hashes every bundle entry, directory listings included).
|
|
136
|
+
// The format is looked up under the canonical key (moduleFileKey, so a
|
|
137
|
+
// package-ROOT listing's rel==='' resolves to the dir, not `${dir}/`); the
|
|
138
|
+
// write path below keeps the literal join, so a NON-directory empty file name
|
|
139
|
+
// still trips the non-canonical-path guard rather than being accepted here.
|
|
140
|
+
if (bundle.formats.get(moduleFileKey(dir, rel)) === 'directory') continue
|
|
141
|
+
const file = dir === '.' ? rel : `${dir}/${rel}`
|
|
142
|
+
if (typeof content !== 'string') throw new Error(`extract: bundle file content is not a string: ${file}`)
|
|
143
|
+
const abs = resolve(outDir, file)
|
|
144
|
+
const relToOut = relative(outDir, abs)
|
|
145
|
+
if (relToOut.startsWith('..') || isAbsolute(relToOut) || !abs.startsWith(outPrefix)) {
|
|
146
|
+
throw new Error(`extract: bundle path escapes output dir: ${file}`)
|
|
147
|
+
}
|
|
148
|
+
// Only canonical paths are accepted: a path that changes under the
|
|
149
|
+
// resolve+relative round-trip (mid-path '..' or '.', empty segments, an
|
|
150
|
+
// empty file name) is nothing State or the Solidity bundler ever emit,
|
|
151
|
+
// and writing it would desync the on-disk tree from the lockfile's
|
|
152
|
+
// recorded paths.
|
|
153
|
+
if (relToOut !== file) throw new Error(`extract: non-canonical bundle path: ${file}`)
|
|
154
|
+
if (targets.has(abs)) throw new Error(`extract: duplicate bundle path: ${file}`)
|
|
155
|
+
if (withLockfile && abs === lockAbs) {
|
|
156
|
+
throw new Error(`extract: bundle contains ${FILE_LOCK}, which would collide with the derived lockfile`)
|
|
157
|
+
}
|
|
158
|
+
targets.add(abs)
|
|
159
|
+
// Resources are decoded back to their original bytes: 'resource:base64' from
|
|
160
|
+
// base64 (rejected if non-canonical -- a tampered bundle can't ship lying
|
|
161
|
+
// padding/invalid-char base64), 'resource' (and code) is written as the raw
|
|
162
|
+
// UTF-8 string.
|
|
163
|
+
const data = bundle.formats.get(file) === 'resource:base64'
|
|
164
|
+
? assertCanonicalBase64(content, file)
|
|
165
|
+
: content
|
|
166
|
+
writes.push([abs, data])
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// A planned file may not also be needed as a parent directory of another --
|
|
170
|
+
// the mkdirSync/writeFileSync pair below would otherwise throw halfway
|
|
171
|
+
// through the writes.
|
|
172
|
+
for (const [abs] of writes) {
|
|
173
|
+
for (let d = dirname(abs); d.length > outDir.length; d = dirname(d)) {
|
|
174
|
+
if (targets.has(d)) {
|
|
175
|
+
throw new Error(`extract: bundle path used as both file and directory: ${relative(outDir, d)}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const lockText = withLockfile ? lockfileFromBundle(bundle).serialize() : null
|
|
180
|
+
|
|
181
|
+
for (const [abs, content] of writes) {
|
|
182
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
183
|
+
writeFileSync(abs, content)
|
|
184
|
+
}
|
|
185
|
+
mkdirSync(outDir, { recursive: true }) // for an empty bundle, where no write created it
|
|
186
|
+
if (withLockfile) writeFileSync(lockAbs, lockText)
|
|
187
|
+
|
|
188
|
+
console.warn(`[stasis] Extracted ${writes.length} file(s)${withLockfile ? ` and ${FILE_LOCK}` : ''} to ${outDir}`)
|
|
189
|
+
if (!withLockfile) {
|
|
190
|
+
console.warn(`[stasis] Warning: legacy v0 bundle records no package name/version, so ${FILE_LOCK} can not be restored and was not written`)
|
|
191
|
+
}
|
|
192
|
+
return { dir: outDir, files: writes.length }
|
|
193
|
+
}
|
package/src/cmd/sbom.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parseFile } from '../parse.js'
|
|
5
|
+
import { collectComponents, generateSbom } from '../sbom.js'
|
|
6
|
+
|
|
7
|
+
// Run the `stasis sbom` CLI command end-to-end: read the given lockfiles/bundles
|
|
8
|
+
// from disk (this is the layer that pulls in brotli, via `parseFile`), build an
|
|
9
|
+
// SBOM in the requested `format` (spdx | cyclonedx) through the file-free
|
|
10
|
+
// `@exodus/stasis/sbom` API, and write the pretty-printed JSON to `output` (a
|
|
11
|
+
// file, or stdout when `output` is `-` or unset). The one-line summary goes to
|
|
12
|
+
// stderr so it never corrupts a document streamed to stdout. `now`/`uuid` are
|
|
13
|
+
// injectable for deterministic output.
|
|
14
|
+
export function sbomCommand({ cwd = process.cwd(), files, format, output, now, uuid } = {}) {
|
|
15
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
16
|
+
throw new Error('sbom: at least one lockfile or bundle is required')
|
|
17
|
+
}
|
|
18
|
+
const artifacts = files.map((f) => parseFile(resolve(cwd, f)))
|
|
19
|
+
const components = collectComponents(artifacts)
|
|
20
|
+
const doc = generateSbom(format, components, { now, uuid })
|
|
21
|
+
const text = JSON.stringify(doc, undefined, 2) + '\n'
|
|
22
|
+
|
|
23
|
+
const target = output ?? '-'
|
|
24
|
+
if (target === '-') {
|
|
25
|
+
process.stdout.write(text)
|
|
26
|
+
} else {
|
|
27
|
+
const outAbs = resolve(cwd, target)
|
|
28
|
+
mkdirSync(dirname(outAbs), { recursive: true })
|
|
29
|
+
writeFileSync(outAbs, text)
|
|
30
|
+
}
|
|
31
|
+
const dest = target === '-' ? '<stdout>' : target
|
|
32
|
+
const n = components.length
|
|
33
|
+
console.warn(`[stasis] Wrote ${format} SBOM with ${n} component${n === 1 ? '' : 's'} to ${dest}`)
|
|
34
|
+
return { components, doc }
|
|
35
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { sortPaths } from '@exodus/stasis-core/util'
|
|
2
|
+
|
|
3
|
+
// `@exodus/stasis/diff` — compare two already-parsed stasis artifacts
|
|
4
|
+
// (`Bundle`/`Lockfile` instances) and report what changed, at both the module
|
|
5
|
+
// (package) and the file level. Like `@exodus/stasis/sbom`, it never reads disk
|
|
6
|
+
// and pulls in no brotli (`node:zlib`) or crypto (`node:crypto`): the caller
|
|
7
|
+
// reads/decompresses the artifacts, hands over parsed instances, and — to
|
|
8
|
+
// compare a bundle's bytes against a lockfile's digests — supplies the hash
|
|
9
|
+
// function (see `hash` below). The CLI glue that reads files off disk and wires
|
|
10
|
+
// in `sha512integrity` lives in `src/cmd/diff.js`.
|
|
11
|
+
//
|
|
12
|
+
// The key to comparing across kinds — diffing a lockfile against a bundle — is
|
|
13
|
+
// that every artifact reduces to the same "digest space". A lockfile already
|
|
14
|
+
// records each file as an SRI digest (`sha512-…`); a bundle records the file's
|
|
15
|
+
// bytes, so we re-hash those bytes (via the injected `hash`) into the very
|
|
16
|
+
// digest a lockfile would have recorded. A bundle stores code and 'resource'
|
|
17
|
+
// files as UTF-8 text and 'resource:base64' files as base64 blobs, so the
|
|
18
|
+
// hashing path is decided PER FILE from the bundle's `formats` map -- not
|
|
19
|
+
// from a whole-bundle kind.
|
|
20
|
+
|
|
21
|
+
const KINDS = new Set(['lockfile', 'bundle'])
|
|
22
|
+
|
|
23
|
+
// Reduce one file's recorded value to the SRI digest a lockfile records for it.
|
|
24
|
+
// 'lockfile' values are already digests; a bundle's values are file bytes that
|
|
25
|
+
// must be re-hashed. The encoding is now per-file (one bundle holds both code and
|
|
26
|
+
// resources): a 'resource:base64' file is a base64 blob decoded back to raw bytes
|
|
27
|
+
// first, while code files and raw-UTF-8 'resource' files hash as their string. This
|
|
28
|
+
// is the "rehash the bundle" step that lets a bundle and a lockfile be compared,
|
|
29
|
+
// and the reason a `hash` function is required whenever a bundle is involved.
|
|
30
|
+
// `hash` takes a string or Buffer and returns the SRI digest (e.g. the
|
|
31
|
+
// `sha512integrity` the CLI injects); a lockfile needs none.
|
|
32
|
+
function digestOf(kind, format, value, hash) {
|
|
33
|
+
if (kind === 'lockfile') return value
|
|
34
|
+
if (typeof hash !== 'function') {
|
|
35
|
+
throw new Error('diff: a `hash` function is required to compare a bundle (its bytes are re-hashed into a lockfile-style digest)')
|
|
36
|
+
}
|
|
37
|
+
if (format === 'resource:base64') return hash(Buffer.from(value, 'base64'))
|
|
38
|
+
return hash(value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Project an artifact onto a uniform shape for diffing: its scope, plus a map of
|
|
42
|
+
// package dir -> { name, version, ecosystem, files: Map<rel, digest> }. `input`
|
|
43
|
+
// is `{ artifact, kind }` where `artifact` is a parsed Bundle/Lockfile and `kind`
|
|
44
|
+
// is 'lockfile' | 'bundle' (see parseFileWithKind). `hash` is the digest function
|
|
45
|
+
// used to re-hash a bundle's bytes; it is only consulted for bundles (a lockfile
|
|
46
|
+
// already holds digests). Per-file encoding is read from the bundle's `formats`.
|
|
47
|
+
export function normalizeArtifact(input, { hash } = {}) {
|
|
48
|
+
const { artifact, kind } = input ?? {}
|
|
49
|
+
if (!artifact || !KINDS.has(kind)) {
|
|
50
|
+
throw new Error("diff: each input must be { artifact, kind } with kind 'lockfile' | 'bundle'")
|
|
51
|
+
}
|
|
52
|
+
const formats = artifact.formats ?? new Map()
|
|
53
|
+
const modules = new Map()
|
|
54
|
+
for (const [dir, { name, version, ecosystem, files }] of artifact.modules) {
|
|
55
|
+
const digests = new Map()
|
|
56
|
+
for (const [rel, value] of Object.entries(files)) {
|
|
57
|
+
const full = dir === '.' ? rel : `${dir}/${rel}`
|
|
58
|
+
digests.set(rel, digestOf(kind, formats.get(full), value, hash))
|
|
59
|
+
}
|
|
60
|
+
// v0 bundles record no name/version (null); normalize undefined to null too
|
|
61
|
+
// so module-metadata comparisons have a single "unknown" sentinel.
|
|
62
|
+
modules.set(dir, { name: name ?? null, version: version ?? null, ecosystem: ecosystem ?? null, files: digests })
|
|
63
|
+
}
|
|
64
|
+
return { scope: artifact.config?.scope ?? 'full', modules }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const projectPath = (dir, rel) => (dir === '.' ? rel : `${dir}/${rel}`)
|
|
68
|
+
|
|
69
|
+
// Diff two artifacts. `left`/`right` are each `{ artifact, kind }`; `left` is the
|
|
70
|
+
// baseline ("from"), `right` the comparison ("to"). Returns a structured report:
|
|
71
|
+
//
|
|
72
|
+
// {
|
|
73
|
+
// scope: { left, right },
|
|
74
|
+
// modules: {
|
|
75
|
+
// added: [{ dir, name, version, ecosystem, files }], // only in right
|
|
76
|
+
// removed: [{ dir, name, version, ecosystem, files }], // only in left
|
|
77
|
+
// changed: [{ dir, name, versionChange?, nameChange? }] // in both, metadata differs
|
|
78
|
+
// },
|
|
79
|
+
// files: { added: [path], removed: [path], differing: [path] },
|
|
80
|
+
// imports?: { attested: { left, right }, added, removed, changed } // only with { imports: true }
|
|
81
|
+
// }
|
|
82
|
+
//
|
|
83
|
+
// Modules are compared whole-package; files are compared within packages present
|
|
84
|
+
// on BOTH sides. A package that exists on only one side is reported once as an
|
|
85
|
+
// added/removed module (carrying its file count) rather than re-listing each of
|
|
86
|
+
// its files — so the two levels are complementary, not redundant. A module's
|
|
87
|
+
// `version`/`name` change is only reported when both sides record a (non-null)
|
|
88
|
+
// value, since a v0 bundle records neither.
|
|
89
|
+
//
|
|
90
|
+
// With `{ imports: true }` the diff also compares the artifacts' resolution
|
|
91
|
+
// graphs (the `imports` edges both lockfiles and bundles carry) and reports
|
|
92
|
+
// which edges were added, removed, or redirected — see `diffImports`. It is
|
|
93
|
+
// opt-in because the edge graph is verbose and not always attested.
|
|
94
|
+
//
|
|
95
|
+
// `hash` is the digest function used to re-hash bundle bytes (the CLI passes
|
|
96
|
+
// `sha512integrity`); it is required only when a bundle is one of
|
|
97
|
+
// the operands — a lockfile↔lockfile diff needs none.
|
|
98
|
+
export function diffArtifacts(left, right, { imports = false, hash } = {}) {
|
|
99
|
+
const L = normalizeArtifact(left, { hash })
|
|
100
|
+
const R = normalizeArtifact(right, { hash })
|
|
101
|
+
|
|
102
|
+
const modulesAdded = []
|
|
103
|
+
const modulesRemoved = []
|
|
104
|
+
const modulesChanged = []
|
|
105
|
+
const filesAdded = []
|
|
106
|
+
const filesRemoved = []
|
|
107
|
+
const filesDiffering = []
|
|
108
|
+
|
|
109
|
+
const dirs = new Set([...L.modules.keys(), ...R.modules.keys()])
|
|
110
|
+
for (const dir of dirs) {
|
|
111
|
+
const l = L.modules.get(dir)
|
|
112
|
+
const r = R.modules.get(dir)
|
|
113
|
+
if (l && !r) {
|
|
114
|
+
modulesRemoved.push({ dir, name: l.name, version: l.version, ecosystem: l.ecosystem, files: l.files.size })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (!l && r) {
|
|
118
|
+
modulesAdded.push({ dir, name: r.name, version: r.version, ecosystem: r.ecosystem, files: r.files.size })
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Present on both sides: compare package metadata, then files within.
|
|
123
|
+
const change = { dir, name: r.name ?? l.name }
|
|
124
|
+
if (l.version !== null && r.version !== null && l.version !== r.version) {
|
|
125
|
+
change.versionChange = { from: l.version, to: r.version }
|
|
126
|
+
}
|
|
127
|
+
if (l.name !== null && r.name !== null && l.name !== r.name) {
|
|
128
|
+
change.nameChange = { from: l.name, to: r.name }
|
|
129
|
+
}
|
|
130
|
+
if (change.versionChange || change.nameChange) modulesChanged.push(change)
|
|
131
|
+
|
|
132
|
+
const rels = new Set([...l.files.keys(), ...r.files.keys()])
|
|
133
|
+
for (const rel of rels) {
|
|
134
|
+
const lh = l.files.get(rel)
|
|
135
|
+
const rh = r.files.get(rel)
|
|
136
|
+
const path = projectPath(dir, rel)
|
|
137
|
+
if (lh !== undefined && rh === undefined) filesRemoved.push(path)
|
|
138
|
+
else if (lh === undefined && rh !== undefined) filesAdded.push(path)
|
|
139
|
+
else if (lh !== rh) filesDiffering.push(path)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const byDir = (a, b) => sortPaths(a.dir, b.dir)
|
|
144
|
+
modulesAdded.sort(byDir)
|
|
145
|
+
modulesRemoved.sort(byDir)
|
|
146
|
+
modulesChanged.sort(byDir)
|
|
147
|
+
filesAdded.sort(sortPaths)
|
|
148
|
+
filesRemoved.sort(sortPaths)
|
|
149
|
+
filesDiffering.sort(sortPaths)
|
|
150
|
+
|
|
151
|
+
const result = {
|
|
152
|
+
scope: { left: L.scope, right: R.scope },
|
|
153
|
+
modules: { added: modulesAdded, removed: modulesRemoved, changed: modulesChanged },
|
|
154
|
+
files: { added: filesAdded, removed: filesRemoved, differing: filesDiffering },
|
|
155
|
+
}
|
|
156
|
+
if (imports) result.imports = diffImports(left, right)
|
|
157
|
+
return result
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The resolution graph an artifact attests, or null when it attests none. A
|
|
161
|
+
// unified bundle always carries a (possibly empty) `imports` Map, so it attests
|
|
162
|
+
// its resolution set -- empty for a resources-only bundle. A lockfile predating
|
|
163
|
+
// resolution attestation records `imports === null` and attests none.
|
|
164
|
+
function attestedImports({ artifact }) {
|
|
165
|
+
return artifact.imports ?? null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Collapse an imports map (conditions -> parent -> specifier -> target) to a map
|
|
169
|
+
// of (parent, specifier) -> Set<target>, unioning over every conditions key.
|
|
170
|
+
// We diff at this *resolution* level — "what does this import resolve to?" —
|
|
171
|
+
// rather than at the raw (conditions, parent, specifier) edge key, because the
|
|
172
|
+
// two sides label conditions differently: a statically built bundle records
|
|
173
|
+
// every edge under the wildcard "*", while a runtime lockfile records precise
|
|
174
|
+
// condition sets like "node, import". Keying on conditions would then report a
|
|
175
|
+
// byte-identical resolution as removed (the "*" edge) + added (the precise
|
|
176
|
+
// edge); folding conditions away makes them compare equal. This mirrors how the
|
|
177
|
+
// loader reconciles "*" against precise sets when verifying a resolution
|
|
178
|
+
// (state.js #assertAttestedResolution): the final target is what matters, not
|
|
179
|
+
// how the edge is keyed.
|
|
180
|
+
function resolutionsOf(imports) {
|
|
181
|
+
const byPair = new Map()
|
|
182
|
+
for (const [, byParent] of imports) {
|
|
183
|
+
for (const [parent, specifiers] of byParent) {
|
|
184
|
+
for (const [specifier, target] of specifiers) {
|
|
185
|
+
const key = JSON.stringify([parent, specifier])
|
|
186
|
+
let entry = byPair.get(key)
|
|
187
|
+
if (!entry) byPair.set(key, (entry = { parent, specifier, targets: new Set() }))
|
|
188
|
+
// A `--metro` edge target may be a { platform: file } map; fold each
|
|
189
|
+
// platform's file into the target set, so a redirect is compared at the
|
|
190
|
+
// "what files does this resolve to" level regardless of platform keying.
|
|
191
|
+
if (target instanceof Map) for (const f of target.values()) entry.targets.add(f)
|
|
192
|
+
else entry.targets.add(target)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return byPair
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
|
|
200
|
+
const cmpPair = (a, b) => sortPaths(a.parent, b.parent) || cmpStr(a.specifier, b.specifier)
|
|
201
|
+
const sortedTargets = (set) => [...set].toSorted(sortPaths)
|
|
202
|
+
const sameTargets = (a, b) => a.size === b.size && [...a].every((t) => b.has(t))
|
|
203
|
+
|
|
204
|
+
// Diff the resolution graphs at the (parent, specifier) level (see
|
|
205
|
+
// `resolutionsOf` for why conditions are folded away). Each side maps a
|
|
206
|
+
// (parent, specifier) to the set of files it resolves to (usually one). Reports:
|
|
207
|
+
// added — a (parent, specifier) only the right artifact resolves
|
|
208
|
+
// removed — a (parent, specifier) only the left artifact resolves
|
|
209
|
+
// changed — resolved on both sides, but to a different set of targets (a
|
|
210
|
+
// redirect — the kind of change a byte/format diff can't see)
|
|
211
|
+
// `from`/`to` are sorted arrays of target files. When either side does not
|
|
212
|
+
// attest imports at all (a legacy lockfile predating `imports`), the
|
|
213
|
+
// comparison is skipped and `attested` flags why, rather than reporting one
|
|
214
|
+
// side's whole graph as added/removed.
|
|
215
|
+
export function diffImports(left, right) {
|
|
216
|
+
const li = attestedImports(left)
|
|
217
|
+
const ri = attestedImports(right)
|
|
218
|
+
const attested = { left: li !== null, right: ri !== null }
|
|
219
|
+
if (li === null || ri === null) return { attested, added: [], removed: [], changed: [] }
|
|
220
|
+
|
|
221
|
+
const L = resolutionsOf(li)
|
|
222
|
+
const R = resolutionsOf(ri)
|
|
223
|
+
const added = []
|
|
224
|
+
const removed = []
|
|
225
|
+
const changed = []
|
|
226
|
+
for (const key of new Set([...L.keys(), ...R.keys()])) {
|
|
227
|
+
const l = L.get(key)
|
|
228
|
+
const r = R.get(key)
|
|
229
|
+
if (l && !r) removed.push({ parent: l.parent, specifier: l.specifier, from: sortedTargets(l.targets) })
|
|
230
|
+
else if (!l && r) added.push({ parent: r.parent, specifier: r.specifier, to: sortedTargets(r.targets) })
|
|
231
|
+
else if (!sameTargets(l.targets, r.targets)) {
|
|
232
|
+
changed.push({ parent: l.parent, specifier: l.specifier, from: sortedTargets(l.targets), to: sortedTargets(r.targets) })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
added.sort(cmpPair)
|
|
236
|
+
removed.sort(cmpPair)
|
|
237
|
+
changed.sort(cmpPair)
|
|
238
|
+
return { attested, added, removed, changed }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// True when the diff records any module-, file-, or (when present) import-level
|
|
242
|
+
// change. Scope alone is informational (a scope difference shows up as the
|
|
243
|
+
// modules/files only one side records) and does not, on its own, count as a
|
|
244
|
+
// difference. Imports that can't be compared (one side doesn't attest them)
|
|
245
|
+
// contribute nothing — their add/remove/change lists are empty.
|
|
246
|
+
export function hasDifferences(diff) {
|
|
247
|
+
const { modules, files, imports } = diff
|
|
248
|
+
let n =
|
|
249
|
+
modules.added.length + modules.removed.length + modules.changed.length +
|
|
250
|
+
files.added.length + files.removed.length + files.differing.length
|
|
251
|
+
if (imports) n += imports.added.length + imports.removed.length + imports.changed.length
|
|
252
|
+
return n > 0
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const KIND_LABEL = { lockfile: 'lockfile', bundle: 'bundle' }
|
|
256
|
+
const plural = (n) => (n === 1 ? '' : 's')
|
|
257
|
+
const ident = ({ name, version }) => (name ? (version ? `${name}@${version}` : name) : '')
|
|
258
|
+
|
|
259
|
+
// Diff markers. `~` reads too close to `-` in a terminal, so a changed/redirected
|
|
260
|
+
// entry is marked `*` (distinct from `-` removed / `+` added).
|
|
261
|
+
const REMOVED = '-'
|
|
262
|
+
const ADDED = '+'
|
|
263
|
+
const CHANGED = '*'
|
|
264
|
+
|
|
265
|
+
function describeModuleChange({ name, versionChange, nameChange }) {
|
|
266
|
+
const segs = []
|
|
267
|
+
if (versionChange) segs.push(`${versionChange.from} -> ${versionChange.to}`)
|
|
268
|
+
if (nameChange) segs.push(`name ${nameChange.from} -> ${nameChange.to}`)
|
|
269
|
+
return `${name ? `${name} ` : ''}${segs.join(', ')}`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const moduleLine = (mark, x) => {
|
|
273
|
+
const id = ident(x)
|
|
274
|
+
return ` ${mark} ${x.dir}${id ? ` ${id}` : ''} (${x.files} file${plural(x.files)})`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// `parent 'specifier'` — conditions are folded away (see resolutionsOf), so an
|
|
278
|
+
// edge is identified by the importing file and the specifier it resolved.
|
|
279
|
+
const edgeLabel = (e) => `${e.parent} '${e.specifier}'`
|
|
280
|
+
const targetList = (files) => files.join(', ')
|
|
281
|
+
|
|
282
|
+
// Render the `--stat` summary of a diff (module + file change lists with
|
|
283
|
+
// counts, plus the import-edge changes when the diff carries them), in the
|
|
284
|
+
// spirit of `git diff --stat`: it reports *what* changed, not the differing
|
|
285
|
+
// text itself, and the whole report — including the trailing differ/no-differ
|
|
286
|
+
// line — goes to stdout. `labels` is `{ left, right, leftKind, rightKind }` for
|
|
287
|
+
// the header. Lines use `-` (only in left / baseline), `+` (only in right),
|
|
288
|
+
// `*` (changed in place). Returns a string ending in a newline.
|
|
289
|
+
export function formatDiffStat(diff, { left = '(left)', right = '(right)', leftKind, rightKind } = {}) {
|
|
290
|
+
const { modules: m, files: f, imports: im, scope } = diff
|
|
291
|
+
const lines = ['stasis diff --stat']
|
|
292
|
+
const label = (kind) => (kind ? `${KIND_LABEL[kind]}, ` : '')
|
|
293
|
+
lines.push(` ${REMOVED} ${left} (${label(leftKind)}scope=${scope.left})`)
|
|
294
|
+
lines.push(` ${ADDED} ${right} (${label(rightKind)}scope=${scope.right})`)
|
|
295
|
+
if (scope.left !== scope.right) {
|
|
296
|
+
lines.push(` note: scopes differ (${scope.left} vs ${scope.right}); files only the full-scope artifact records show as removed/added`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
lines.push('')
|
|
300
|
+
lines.push(`Modules: ${m.added.length} added, ${m.removed.length} removed, ${m.changed.length} changed`)
|
|
301
|
+
for (const x of m.removed) lines.push(moduleLine(REMOVED, x))
|
|
302
|
+
for (const x of m.added) lines.push(moduleLine(ADDED, x))
|
|
303
|
+
for (const x of m.changed) lines.push(` ${CHANGED} ${x.dir} ${describeModuleChange(x)}`)
|
|
304
|
+
|
|
305
|
+
lines.push('')
|
|
306
|
+
// Counts cover files *within modules present on both sides*; files inside a
|
|
307
|
+
// wholly added/removed module are summarized by its module entry above (with
|
|
308
|
+
// a file count), not re-counted here — so name the scope to avoid misreading
|
|
309
|
+
// "0 removed" when a whole package vanished.
|
|
310
|
+
lines.push(`Files (within shared modules): ${f.added.length} added, ${f.removed.length} removed, ${f.differing.length} differing`)
|
|
311
|
+
for (const p of f.removed) lines.push(` ${REMOVED} ${p}`)
|
|
312
|
+
for (const p of f.added) lines.push(` ${ADDED} ${p}`)
|
|
313
|
+
for (const p of f.differing) lines.push(` ${CHANGED} ${p}`)
|
|
314
|
+
|
|
315
|
+
if (im) {
|
|
316
|
+
lines.push('')
|
|
317
|
+
if (!im.attested.left || !im.attested.right) {
|
|
318
|
+
const which = !im.attested.left && !im.attested.right
|
|
319
|
+
? 'neither artifact attests'
|
|
320
|
+
: `${im.attested.left ? right : left} does not attest`
|
|
321
|
+
lines.push(`Imports: ${which} resolutions; skipping import diff`)
|
|
322
|
+
} else {
|
|
323
|
+
lines.push(`Imports: ${im.added.length} added, ${im.removed.length} removed, ${im.changed.length} changed`)
|
|
324
|
+
for (const e of im.removed) lines.push(` ${REMOVED} ${edgeLabel(e)} -> ${targetList(e.from)}`)
|
|
325
|
+
for (const e of im.added) lines.push(` ${ADDED} ${edgeLabel(e)} -> ${targetList(e.to)}`)
|
|
326
|
+
for (const e of im.changed) lines.push(` ${CHANGED} ${edgeLabel(e)} ${targetList(e.from)} -> ${targetList(e.to)}`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
lines.push('')
|
|
331
|
+
lines.push(hasDifferences(diff) ? 'Artifacts differ' : 'No differences')
|
|
332
|
+
return lines.join('\n') + '\n'
|
|
333
|
+
}
|
package/src/esbuild.js
CHANGED
|
@@ -1,56 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import { State } from './state.js'
|
|
8
|
-
|
|
9
|
-
const state = State.instance
|
|
10
|
-
assert.ok(state, 'Stasis preload is not active')
|
|
11
|
-
|
|
12
|
-
export class StasisEsbuild {
|
|
13
|
-
#seen = new Set()
|
|
14
|
-
|
|
15
|
-
get name() {
|
|
16
|
-
return 'stasis'
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
setup = ({ onResolve, onLoad, resolve }) => {
|
|
20
|
-
onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, ...args }) => {
|
|
21
|
-
// Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
|
|
22
|
-
const res = await resolve(specifier, { ...args, namespace: 'stasis' })
|
|
23
|
-
if (res.errors.length > 0) return res
|
|
24
|
-
assert.equal(res.warnings.length, 0)
|
|
25
|
-
assert.equal(res.suffix, '')
|
|
26
|
-
|
|
27
|
-
const isEntry = !args.importer
|
|
28
|
-
if (res.namespace === 'file' && !res.external) {
|
|
29
|
-
if (!isEntry) {
|
|
30
|
-
const parentURL = pathToFileURL(args.importer).toString()
|
|
31
|
-
const url = pathToFileURL(res.path).toString()
|
|
32
|
-
state.addImport(parentURL, specifier, url)
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return { ...res, pluginData: { ...res.pluginData, isEntry } }
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
|
|
40
|
-
assert.equal(Object.keys(attrs).length, 0) // TODO: 'with' statements
|
|
41
|
-
const source = await readFile(path)
|
|
42
|
-
const isBinary = !isUtf8(source)
|
|
43
|
-
|
|
44
|
-
if (!this.#seen.has(path)) {
|
|
45
|
-
this.#seen.add(path)
|
|
46
|
-
state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, isBinary })
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (isBinary) return { contents: source, loader: 'binary' }
|
|
50
|
-
|
|
51
|
-
const loader = extname(path).replace(/^\.[cm]?/, '')
|
|
52
|
-
assert(['js', 'ts', 'jsx', 'tsx', 'json'].includes(loader))
|
|
53
|
-
return { contents: source, loader }
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
// Public-export adapter: backs `@exodus/stasis/esbuild`. The plugin lives in
|
|
2
|
+
// @exodus/stasis-plugins (which depends on @exodus/stasis-core for State /
|
|
3
|
+
// resolvePluginState). Internal code uses `@exodus/stasis-plugins/esbuild` directly;
|
|
4
|
+
// this file exists only to back the package's `exports` map.
|
|
5
|
+
export * from '@exodus/stasis-plugins/esbuild'
|