@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.3
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 +16 -2
- package/bin/stasis.js +173 -161
- package/package.json +11 -5
- package/src/apis/npm/semver.cjs +0 -2
- package/src/audit.js +161 -22
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/cmd/build.js +93 -123
- package/src/cmd/bundle.js +470 -427
- package/src/cmd/diff.js +2 -19
- package/src/cmd/sbom.js +1 -7
- package/src/diff.js +61 -118
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +33 -81
- package/src/loaders/php.js +143 -282
- package/src/loaders/rust.js +37 -93
- package/src/loaders/solidity.js +27 -52
- package/src/metro-resolver.js +165 -0
- package/src/metro-transformer.js +3 -2
- package/src/mock.js +54 -160
- package/src/parse.js +4 -16
- package/src/sbom.js +37 -79
- package/src/scan.js +166 -124
- package/src/cmd/extract.js +0 -193
package/src/cmd/diff.js
CHANGED
|
@@ -4,25 +4,8 @@ import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
|
4
4
|
import { parseFileWithKind } from '../parse.js'
|
|
5
5
|
import { diffArtifacts, formatDiffStat, hasDifferences } from '../diff.js'
|
|
6
6
|
|
|
7
|
-
// Run
|
|
8
|
-
//
|
|
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)`).
|
|
7
|
+
// Run `stasis diff`: read both lockfiles/bundles from disk and diff them through
|
|
8
|
+
// the file-free diff API. Returns { diff, differences } for the caller's exit code.
|
|
26
9
|
export function diffCommand({ cwd = process.cwd(), left, right, stat, imports = false, out = process.stdout } = {}) {
|
|
27
10
|
if (!left || !right) throw new Error('diff: two files (lockfile or bundle) are required')
|
|
28
11
|
if (!stat) throw new Error('diff: only --stat is implemented; pass stat: true')
|
package/src/cmd/sbom.js
CHANGED
|
@@ -4,13 +4,7 @@ import { dirname, resolve } from 'node:path'
|
|
|
4
4
|
import { parseFile } from '../parse.js'
|
|
5
5
|
import { collectComponents, generateSbom } from '../sbom.js'
|
|
6
6
|
|
|
7
|
-
//
|
|
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.
|
|
7
|
+
// The one-line summary goes to stderr so it never corrupts an SBOM streamed to stdout.
|
|
14
8
|
export function sbomCommand({ cwd = process.cwd(), files, format, output, now, uuid } = {}) {
|
|
15
9
|
if (!Array.isArray(files) || files.length === 0) {
|
|
16
10
|
throw new Error('sbom: at least one lockfile or bundle is required')
|
package/src/diff.js
CHANGED
|
@@ -1,34 +1,15 @@
|
|
|
1
1
|
import { sortPaths } from '@exodus/stasis-core/util'
|
|
2
2
|
|
|
3
|
-
// `@exodus/stasis/diff` — compare two
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
3
|
+
// `@exodus/stasis/diff` — compare two parsed stasis artifacts (Bundle/Lockfile) at the module and
|
|
4
|
+
// file level. Never reads disk. Comparing across kinds works by reducing every file to the same
|
|
5
|
+
// digest space: a lockfile already holds SRI digests; a bundle holds bytes, re-hashed via the
|
|
6
|
+
// injected `hash` — so a `hash` function is required whenever a bundle is an operand. Per-file
|
|
7
|
+
// encoding (resource:base64 vs text) is read from the bundle's `formats` map.
|
|
20
8
|
|
|
21
9
|
const KINDS = new Set(['lockfile', 'bundle'])
|
|
22
10
|
|
|
23
|
-
// Reduce one file's recorded value to
|
|
24
|
-
// '
|
|
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.
|
|
11
|
+
// Reduce one file's recorded value to an SRI digest: lockfile values already are digests; a
|
|
12
|
+
// bundle's are re-hashed, with a `resource:base64` file decoded to bytes first (others hash as text).
|
|
32
13
|
function digestOf(kind, format, value, hash) {
|
|
33
14
|
if (kind === 'lockfile') return value
|
|
34
15
|
if (typeof hash !== 'function') {
|
|
@@ -38,12 +19,8 @@ function digestOf(kind, format, value, hash) {
|
|
|
38
19
|
return hash(value)
|
|
39
20
|
}
|
|
40
21
|
|
|
41
|
-
// Project an artifact onto a uniform shape
|
|
42
|
-
//
|
|
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`.
|
|
22
|
+
// Project an artifact onto a uniform shape: scope + Map<dir, { name, version, ecosystem,
|
|
23
|
+
// files: Map<rel, digest> }>. `input` is { artifact, kind }; `hash` re-hashes bundle bytes only.
|
|
47
24
|
export function normalizeArtifact(input, { hash } = {}) {
|
|
48
25
|
const { artifact, kind } = input ?? {}
|
|
49
26
|
if (!artifact || !KINDS.has(kind)) {
|
|
@@ -57,44 +34,21 @@ export function normalizeArtifact(input, { hash } = {}) {
|
|
|
57
34
|
const full = dir === '.' ? rel : `${dir}/${rel}`
|
|
58
35
|
digests.set(rel, digestOf(kind, formats.get(full), value, hash))
|
|
59
36
|
}
|
|
60
|
-
// v0 bundles record no name/version
|
|
61
|
-
// so module-metadata comparisons have a single "unknown" sentinel.
|
|
37
|
+
// v0 bundles record no name/version; normalize undefined -> null for a single "unknown" sentinel.
|
|
62
38
|
modules.set(dir, { name: name ?? null, version: version ?? null, ecosystem: ecosystem ?? null, files: digests })
|
|
63
39
|
}
|
|
64
|
-
|
|
40
|
+
// `executable` decides which files `stasis extract` chmods +x, so a change to it changes the tree
|
|
41
|
+
// an artifact produces even when every digest matches -- it has to be part of "did these differ?".
|
|
42
|
+
return { scope: artifact.config?.scope ?? 'full', modules, executable: artifact.executable ?? new Set() }
|
|
65
43
|
}
|
|
66
44
|
|
|
67
45
|
const projectPath = (dir, rel) => (dir === '.' ? rel : `${dir}/${rel}`)
|
|
68
46
|
|
|
69
|
-
// Diff two
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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.
|
|
47
|
+
// Diff two `{ artifact, kind }` operands (`left` = baseline/"from", `right` = "to"). Modules are
|
|
48
|
+
// compared whole-package; files only within packages present on BOTH sides (a one-sided package is
|
|
49
|
+
// reported once as an added/removed module, not per-file). A version/name change is reported only
|
|
50
|
+
// when both sides record a non-null value. `{ imports: true }` also diffs the resolution graphs
|
|
51
|
+
// (opt-in; verbose). `hash` is required only when a bundle is an operand.
|
|
98
52
|
export function diffArtifacts(left, right, { imports = false, hash } = {}) {
|
|
99
53
|
const L = normalizeArtifact(left, { hash })
|
|
100
54
|
const R = normalizeArtifact(right, { hash })
|
|
@@ -119,7 +73,6 @@ export function diffArtifacts(left, right, { imports = false, hash } = {}) {
|
|
|
119
73
|
continue
|
|
120
74
|
}
|
|
121
75
|
|
|
122
|
-
// Present on both sides: compare package metadata, then files within.
|
|
123
76
|
const change = { dir, name: r.name ?? l.name }
|
|
124
77
|
if (l.version !== null && r.version !== null && l.version !== r.version) {
|
|
125
78
|
change.versionChange = { from: l.version, to: r.version }
|
|
@@ -148,35 +101,32 @@ export function diffArtifacts(left, right, { imports = false, hash } = {}) {
|
|
|
148
101
|
filesRemoved.sort(sortPaths)
|
|
149
102
|
filesDiffering.sort(sortPaths)
|
|
150
103
|
|
|
104
|
+
// Execute-bit changes, reported for every path either side marks (not just files in shared
|
|
105
|
+
// modules): gaining or losing +x is a real change to the extracted tree, whatever bucket it sits in.
|
|
106
|
+
const onlyIn = (a, b) => [...a].filter((file) => !b.has(file)).toSorted(sortPaths)
|
|
107
|
+
const executableAdded = onlyIn(R.executable, L.executable)
|
|
108
|
+
const executableRemoved = onlyIn(L.executable, R.executable)
|
|
109
|
+
|
|
151
110
|
const result = {
|
|
152
111
|
scope: { left: L.scope, right: R.scope },
|
|
153
112
|
modules: { added: modulesAdded, removed: modulesRemoved, changed: modulesChanged },
|
|
154
113
|
files: { added: filesAdded, removed: filesRemoved, differing: filesDiffering },
|
|
114
|
+
executable: { added: executableAdded, removed: executableRemoved },
|
|
155
115
|
}
|
|
156
116
|
if (imports) result.imports = diffImports(left, right)
|
|
157
117
|
return result
|
|
158
118
|
}
|
|
159
119
|
|
|
160
|
-
// The resolution graph an artifact attests, or null when it attests none
|
|
161
|
-
//
|
|
162
|
-
// its resolution set -- empty for a resources-only bundle. A lockfile predating
|
|
163
|
-
// resolution attestation records `imports === null` and attests none.
|
|
120
|
+
// The resolution graph an artifact attests, or null when it attests none (a legacy lockfile
|
|
121
|
+
// predating attestation); a bundle always carries a possibly-empty map.
|
|
164
122
|
function attestedImports({ artifact }) {
|
|
165
123
|
return artifact.imports ?? null
|
|
166
124
|
}
|
|
167
125
|
|
|
168
|
-
// Collapse an imports map (conditions -> parent -> specifier -> target) to
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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.
|
|
126
|
+
// Collapse an imports map (conditions -> parent -> specifier -> target) to (parent, specifier) ->
|
|
127
|
+
// Set<target>, unioning over conditions: a static bundle keys every edge under "*" while a runtime
|
|
128
|
+
// lockfile uses precise sets, so keying on conditions would report identical resolutions as
|
|
129
|
+
// removed+added. Folding conditions away makes them compare equal.
|
|
180
130
|
function resolutionsOf(imports) {
|
|
181
131
|
const byPair = new Map()
|
|
182
132
|
for (const [, byParent] of imports) {
|
|
@@ -185,9 +135,7 @@ function resolutionsOf(imports) {
|
|
|
185
135
|
const key = JSON.stringify([parent, specifier])
|
|
186
136
|
let entry = byPair.get(key)
|
|
187
137
|
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.
|
|
138
|
+
// A `--metro` edge target may be a { platform: file } map; fold each platform's file into the set.
|
|
191
139
|
if (target instanceof Map) for (const f of target.values()) entry.targets.add(f)
|
|
192
140
|
else entry.targets.add(target)
|
|
193
141
|
}
|
|
@@ -201,17 +149,10 @@ const cmpPair = (a, b) => sortPaths(a.parent, b.parent) || cmpStr(a.specifier, b
|
|
|
201
149
|
const sortedTargets = (set) => [...set].toSorted(sortPaths)
|
|
202
150
|
const sameTargets = (a, b) => a.size === b.size && [...a].every((t) => b.has(t))
|
|
203
151
|
|
|
204
|
-
// Diff
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
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.
|
|
152
|
+
// Diff resolution graphs at the (parent, specifier) level (conditions folded away — see
|
|
153
|
+
// resolutionsOf). Reports added/removed and changed (resolved on both sides to different targets —
|
|
154
|
+
// a redirect a byte diff can't see). When either side doesn't attest imports, the diff is skipped
|
|
155
|
+
// and `attested` flags why, rather than reporting one whole graph as added/removed.
|
|
215
156
|
export function diffImports(left, right) {
|
|
216
157
|
const li = attestedImports(left)
|
|
217
158
|
const ri = attestedImports(right)
|
|
@@ -238,16 +179,17 @@ export function diffImports(left, right) {
|
|
|
238
179
|
return { attested, added, removed, changed }
|
|
239
180
|
}
|
|
240
181
|
|
|
241
|
-
// True when the diff records any module-, file-, or
|
|
242
|
-
//
|
|
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.
|
|
182
|
+
// True when the diff records any module-, file-, or import-level change. Scope alone is
|
|
183
|
+
// informational and doesn't count (unattested imports contribute empty lists).
|
|
246
184
|
export function hasDifferences(diff) {
|
|
247
|
-
|
|
185
|
+
// `executable` is unguarded like modules/files -- diffArtifacts always sets it, and a shape guard
|
|
186
|
+
// here would make a renamed facet read as "no differences" and quietly pass a review gate.
|
|
187
|
+
// `imports` is genuinely opt-in (the `imports` option), so it keeps its guard.
|
|
188
|
+
const { modules, files, executable, imports } = diff
|
|
248
189
|
let n =
|
|
249
190
|
modules.added.length + modules.removed.length + modules.changed.length +
|
|
250
|
-
files.added.length + files.removed.length + files.differing.length
|
|
191
|
+
files.added.length + files.removed.length + files.differing.length +
|
|
192
|
+
executable.added.length + executable.removed.length
|
|
251
193
|
if (imports) n += imports.added.length + imports.removed.length + imports.changed.length
|
|
252
194
|
return n > 0
|
|
253
195
|
}
|
|
@@ -256,8 +198,7 @@ const KIND_LABEL = { lockfile: 'lockfile', bundle: 'bundle' }
|
|
|
256
198
|
const plural = (n) => (n === 1 ? '' : 's')
|
|
257
199
|
const ident = ({ name, version }) => (name ? (version ? `${name}@${version}` : name) : '')
|
|
258
200
|
|
|
259
|
-
// Diff markers
|
|
260
|
-
// entry is marked `*` (distinct from `-` removed / `+` added).
|
|
201
|
+
// Diff markers: `*` (changed) — `~` reads too close to `-` in a terminal.
|
|
261
202
|
const REMOVED = '-'
|
|
262
203
|
const ADDED = '+'
|
|
263
204
|
const CHANGED = '*'
|
|
@@ -274,20 +215,15 @@ const moduleLine = (mark, x) => {
|
|
|
274
215
|
return ` ${mark} ${x.dir}${id ? ` ${id}` : ''} (${x.files} file${plural(x.files)})`
|
|
275
216
|
}
|
|
276
217
|
|
|
277
|
-
// `parent 'specifier'` —
|
|
278
|
-
// edge is identified by the importing file and the specifier it resolved.
|
|
218
|
+
// `parent 'specifier'` — an edge is identified by importer + specifier (conditions folded away).
|
|
279
219
|
const edgeLabel = (e) => `${e.parent} '${e.specifier}'`
|
|
280
220
|
const targetList = (files) => files.join(', ')
|
|
281
221
|
|
|
282
|
-
// Render the `--stat` summary
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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.
|
|
222
|
+
// Render the `--stat` summary (module + file change lists with counts, plus import-edge changes
|
|
223
|
+
// when present); reports what changed, not the text. `labels` is { left, right, leftKind, rightKind }.
|
|
224
|
+
// Returns a string ending in a newline.
|
|
289
225
|
export function formatDiffStat(diff, { left = '(left)', right = '(right)', leftKind, rightKind } = {}) {
|
|
290
|
-
const { modules: m, files: f, imports: im, scope } = diff
|
|
226
|
+
const { modules: m, files: f, executable: ex, imports: im, scope } = diff
|
|
291
227
|
const lines = ['stasis diff --stat']
|
|
292
228
|
const label = (kind) => (kind ? `${KIND_LABEL[kind]}, ` : '')
|
|
293
229
|
lines.push(` ${REMOVED} ${left} (${label(leftKind)}scope=${scope.left})`)
|
|
@@ -303,15 +239,22 @@ export function formatDiffStat(diff, { left = '(left)', right = '(right)', leftK
|
|
|
303
239
|
for (const x of m.changed) lines.push(` ${CHANGED} ${x.dir} ${describeModuleChange(x)}`)
|
|
304
240
|
|
|
305
241
|
lines.push('')
|
|
306
|
-
// Counts cover files
|
|
307
|
-
//
|
|
308
|
-
// a file count), not re-counted here — so name the scope to avoid misreading
|
|
309
|
-
// "0 removed" when a whole package vanished.
|
|
242
|
+
// Counts cover only files within modules present on both sides; a wholly added/removed module's
|
|
243
|
+
// files are summarized by its module entry above — hence the "within shared modules" label.
|
|
310
244
|
lines.push(`Files (within shared modules): ${f.added.length} added, ${f.removed.length} removed, ${f.differing.length} differing`)
|
|
311
245
|
for (const p of f.removed) lines.push(` ${REMOVED} ${p}`)
|
|
312
246
|
for (const p of f.added) lines.push(` ${ADDED} ${p}`)
|
|
313
247
|
for (const p of f.differing) lines.push(` ${CHANGED} ${p}`)
|
|
314
248
|
|
|
249
|
+
// Only rendered when something changed: an unchanged execute-bit set is the overwhelming norm,
|
|
250
|
+
// and a permanent "0 added, 0 removed" line would be noise in every diff.
|
|
251
|
+
if (ex.added.length > 0 || ex.removed.length > 0) {
|
|
252
|
+
lines.push('')
|
|
253
|
+
lines.push(`Executable: ${ex.added.length} added, ${ex.removed.length} removed`)
|
|
254
|
+
for (const p of ex.removed) lines.push(` ${REMOVED} ${p}`)
|
|
255
|
+
for (const p of ex.added) lines.push(` ${ADDED} ${p}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
315
258
|
if (im) {
|
|
316
259
|
lines.push('')
|
|
317
260
|
if (!im.attested.left || !im.attested.right) {
|
package/src/loader-mock.js
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
// stasis's `--mock`
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// JS and calls module.syncBuiltinESMExports() so user code's later ESM imports
|
|
8
|
-
// resolve to the mocked builtins.
|
|
9
|
-
// Only THEN install() the hooks. Running mock.js before registration keeps it from
|
|
10
|
-
// going through the hooks (so it never becomes the bundle entry), and running it
|
|
11
|
-
// after the lib's snapshots leaves stasis's own bindings untouched. This static
|
|
12
|
-
// composition is what lets core stay mock-agnostic with no env-var coordination.
|
|
1
|
+
// stasis's `--mock` loader entry. The import order is load-bearing:
|
|
2
|
+
// 1. the core hooks lib -- evaluating it snapshots stasis's own fs bindings off the *real*
|
|
3
|
+
// builtins, so stasis keeps reading/writing through real fs;
|
|
4
|
+
// 2. ./mock.js -- neutralizes the network/timer builtins and fs writers, then calls
|
|
5
|
+
// syncBuiltinESMExports() so user code's later ESM imports resolve to the mocks.
|
|
6
|
+
// Only THEN install() the hooks (so mock.js never goes through them / becomes the bundle entry).
|
|
13
7
|
import { install } from '@exodus/stasis-core/hooks'
|
|
14
|
-
// `export *` (mock.js has no exports) rather than a bare side-effect import
|
|
15
|
-
//
|
|
16
|
-
// source order, after the hooks lib above and before install() below.
|
|
8
|
+
// `export *` (mock.js has no exports) rather than a bare side-effect import, to satisfy the
|
|
9
|
+
// no-unassigned-import lint rule; ESM still evaluates it here in source order.
|
|
17
10
|
export * from './mock.js'
|
|
18
11
|
|
|
19
12
|
install()
|
package/src/loaders/bash.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
// Bash loader:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// dangling dependency. Everything else is best-effort and dropped: PATH
|
|
7
|
-
// commands (`grep`), `$VAR` paths, extensionless `source ./env`, absolute
|
|
8
|
-
// `source /etc/x.sh`, and `../` paths that escape the root — none is bundle-able.
|
|
9
|
-
// A `source "${VAR}/x.sh"` whose path can't be evaluated statically is picked
|
|
10
|
-
// up from its `# shellcheck source=...` directive when one is present.
|
|
1
|
+
// Bash loader: `{ sources, resolutions, missing }` from a set of scripts. A
|
|
2
|
+
// .sh/.bash reference landing INSIDE the bundle root must resolve to an in-set
|
|
3
|
+
// script, else it lands in `missing`; everything else (PATH commands, `$VAR`
|
|
4
|
+
// paths, absolute/escaping paths) is best-effort and dropped. A dynamically
|
|
5
|
+
// sourced path can be pinned by a `# shellcheck source=...` directive.
|
|
11
6
|
|
|
12
7
|
import { realpathSync, statSync } from 'node:fs'
|
|
13
8
|
import { readFile } from 'node:fs/promises'
|
|
@@ -15,16 +10,13 @@ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path
|
|
|
15
10
|
|
|
16
11
|
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
17
12
|
|
|
18
|
-
// The capture class excludes
|
|
19
|
-
//
|
|
20
|
-
// `` `source x.sh` `` or `$(bash x.sh)` — doesn't swallow the closing delimiter
|
|
21
|
-
// into the path (which would silently drop the edge from the bundle).
|
|
13
|
+
// The capture class excludes backtick/`(`/`)` so a ref inside a command
|
|
14
|
+
// substitution (`` `source x.sh` ``, `$(bash x.sh)`) doesn't swallow the closing delimiter.
|
|
22
15
|
|
|
23
16
|
// `source script.sh`, `. script.sh`, `. ./script.sh`
|
|
24
17
|
const SOURCE_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*(?:source|\.)\s+["']?([^\s"';&|#`()]+)["']?/gu
|
|
25
18
|
|
|
26
|
-
// `bash script.sh`,
|
|
27
|
-
// `/bin/sh script.sh`, `/usr/bin/bash script.sh`, etc.
|
|
19
|
+
// `bash`/`sh script.sh`, incl. `/usr/bin/env bash`, `/bin/sh`, `/usr/bin/bash`.
|
|
28
20
|
const EXEC_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*(?:\/usr\/bin\/env\s+|\/bin\/|\/usr\/bin\/)?(?:ba)?sh\s+["']?([^\s"';&|#`()]+)["']?/gu
|
|
29
21
|
|
|
30
22
|
// Direct invocation: `./script.sh`, `../lib/helper.bash`, `/path/to/script.sh`
|
|
@@ -33,21 +25,13 @@ const DIRECT_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*["']?(\.\.?\/[^\s"';&|#`()]+\.(?:
|
|
|
33
25
|
// Dependency hint comment: `# Depends on: example.sh`
|
|
34
26
|
const COMMENT_RE = /^# Depends on: ([^ ]+\.(?:sh|bash))( |$)/gmu
|
|
35
27
|
|
|
36
|
-
// shellcheck `source=` directive: `# shellcheck source=../lib/config.sh
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// sourced file lives — e.g. `source "${LIB_DIR}/config.sh"`, whose path is a
|
|
40
|
-
// `$VAR` expansion that can't be evaluated statically. The path is resolved
|
|
41
|
-
// relative to the script (matching shellcheck's default). `source=/dev/null`
|
|
42
|
-
// is shellcheck's "don't follow this source" sentinel and is ignored below.
|
|
28
|
+
// shellcheck `source=` directive: `# shellcheck source=../lib/config.sh`, the
|
|
29
|
+
// explicit way to point at a dynamically sourced (`$VAR`) file. Resolved relative
|
|
30
|
+
// to the script. `source=/dev/null` is shellcheck's "don't follow" sentinel, ignored below.
|
|
43
31
|
const SHELLCHECK_SOURCE_RE = /#\s*shellcheck\s[^\n]*?\bsource=["']?([^\s"'#]+)["']?/gu
|
|
44
32
|
|
|
45
|
-
// Extract
|
|
46
|
-
//
|
|
47
|
-
// here — they are never bundle-able file paths; a `source "${VAR}/x.sh"` whose
|
|
48
|
-
// real location can't be computed statically should carry a `# shellcheck
|
|
49
|
-
// source=` directive instead. The remaining strings are candidate references;
|
|
50
|
-
// resolveBashCall decides which name a real file.
|
|
33
|
+
// Extract candidate file references. `$VAR` expansions and `-flags` are dropped
|
|
34
|
+
// (never bundle-able); resolveBashCall decides which of the rest name a real file.
|
|
51
35
|
export function extractBashCalls(content) {
|
|
52
36
|
const calls = new Set()
|
|
53
37
|
for (const re of [SOURCE_RE, EXEC_RE, DIRECT_RE, COMMENT_RE, SHELLCHECK_SOURCE_RE]) {
|
|
@@ -60,26 +44,9 @@ export function extractBashCalls(content) {
|
|
|
60
44
|
return [...calls]
|
|
61
45
|
}
|
|
62
46
|
|
|
63
|
-
// Resolve a bash reference to a baseDir-relative POSIX path. Existence is
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// graph); with neither, only path computation happens and unvalidated names
|
|
67
|
-
// return null.
|
|
68
|
-
//
|
|
69
|
-
// Reference kinds are treated differently:
|
|
70
|
-
// - absolute (`/x`): mapped to project-relative by stripping the leading
|
|
71
|
-
// slash, accepted only if that file actually exists in the set/on disk
|
|
72
|
-
// (so system paths like /etc/profile.d/x.sh are dropped, not bundled).
|
|
73
|
-
// - relative / project-relative WITH a slash (`./x`, `../x`, `lib/x.sh`):
|
|
74
|
-
// resolved against the caller's directory and returned as a computed
|
|
75
|
-
// path WITHOUT an existence check — the walk's readFile surfaces a
|
|
76
|
-
// missing target and the tree flags or drops it, mirroring the Solidity
|
|
77
|
-
// loader. Traversal above baseDir returns null rather than clamping, so
|
|
78
|
-
// an escaping `../x.sh` is treated as external (never flagged missing).
|
|
79
|
-
// - bare name (`helper.sh`, but also `grep`, `node`): could be a sibling
|
|
80
|
-
// script or a PATH command, so it is accepted only when it names a real
|
|
81
|
-
// file — caller's dir, then project root, then (knownFiles only) any
|
|
82
|
-
// in-set file by basename.
|
|
47
|
+
// Resolve a bash reference to a baseDir-relative POSIX path. Existence is checked
|
|
48
|
+
// against `knownFiles` (tree mode) or the filesystem under `baseDir` (walk mode);
|
|
49
|
+
// with neither, only path computation happens. Handling differs by kind (below).
|
|
83
50
|
export function resolveBashCall(ref, fromFile, { knownFiles, baseDir } = {}) {
|
|
84
51
|
const present = (p) => {
|
|
85
52
|
if (!p) return false
|
|
@@ -110,13 +77,13 @@ export function resolveBashCall(ref, fromFile, { knownFiles, baseDir } = {}) {
|
|
|
110
77
|
return out.join('/') || null
|
|
111
78
|
}
|
|
112
79
|
|
|
113
|
-
// Absolute → project-relative,
|
|
80
|
+
// Absolute → project-relative, accepted only if it exists (so `/etc/...` is dropped).
|
|
114
81
|
if (ref.startsWith('/')) {
|
|
115
82
|
const stripped = ref.replace(/^\/+/u, '')
|
|
116
83
|
return present(stripped) ? stripped : null
|
|
117
84
|
}
|
|
118
85
|
|
|
119
|
-
// Path with a slash → computed
|
|
86
|
+
// Path with a slash → computed against the caller's dir; existence deferred to the caller.
|
|
120
87
|
if (ref.includes('/')) return resolveRel(ref)
|
|
121
88
|
|
|
122
89
|
// Bare name → only accept a real file.
|
|
@@ -132,19 +99,13 @@ export function resolveBashCall(ref, fromFile, { knownFiles, baseDir } = {}) {
|
|
|
132
99
|
return null
|
|
133
100
|
}
|
|
134
101
|
|
|
135
|
-
//
|
|
136
|
-
// an extensionless `source ./env`, which we don't treat as a missing script
|
|
137
|
-
// when absent).
|
|
102
|
+
// True for `.sh`/`.bash` refs; an extensionless `source ./env` isn't flagged missing when absent.
|
|
138
103
|
const hasShellScriptExt = (ref) => ref.endsWith('.sh') || ref.endsWith('.bash')
|
|
139
104
|
|
|
140
|
-
// Build the
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// References that resolve to null are external and tolerated: bare names
|
|
145
|
-
// (PATH commands / runtime-cwd scripts), absolute `/etc/...` paths, `$VAR`
|
|
146
|
-
// paths, and `../` paths that escape the root — none can live in the bundle.
|
|
147
|
-
// Self-references and non-script extensions are dropped without flagging.
|
|
105
|
+
// Build the triple from already-loaded scripts. A ref is `missing` (fatal) only
|
|
106
|
+
// when it resolves INSIDE the bundle root but wasn't collected (a dangling
|
|
107
|
+
// `source ./lib.sh`); null-resolving refs are external and tolerated, and
|
|
108
|
+
// self-refs/non-script exts are dropped silently.
|
|
148
109
|
export function buildBashTree(sources) {
|
|
149
110
|
const knownFiles = new Set(sources.keys())
|
|
150
111
|
const resolutions = new Map()
|
|
@@ -152,9 +113,8 @@ export function buildBashTree(sources) {
|
|
|
152
113
|
for (const [path, content] of sources) {
|
|
153
114
|
const specMap = new Map()
|
|
154
115
|
for (const ref of extractBashCalls(content)) {
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// file is a genuine dangling local dependency; a null target is external.
|
|
116
|
+
// Slash branch returns an unchecked in-bundle path, so a non-null target not
|
|
117
|
+
// in knownFiles is a genuine dangling dep; a null target is external.
|
|
158
118
|
const target = resolveBashCall(ref, path, { knownFiles })
|
|
159
119
|
if (target && knownFiles.has(target)) {
|
|
160
120
|
if (target !== path) specMap.set(ref, target) // drop self-references
|
|
@@ -168,12 +128,8 @@ export function buildBashTree(sources) {
|
|
|
168
128
|
return { sources, resolutions, missing }
|
|
169
129
|
}
|
|
170
130
|
|
|
171
|
-
// Walk
|
|
172
|
-
//
|
|
173
|
-
// same wave are read in parallel; the next wave depends on references found
|
|
174
|
-
// in the previous one. A reference that resolves to a path missing on disk
|
|
175
|
-
// warns and is skipped (the graph stays partial but the walk continues);
|
|
176
|
-
// unresolved references (commands, dynamic paths) are silently ignored.
|
|
131
|
+
// Walk from `entries` following resolved refs, reading each script once; a ref
|
|
132
|
+
// missing on disk warns and is skipped without aborting the walk.
|
|
177
133
|
export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
178
134
|
const sources = new Map()
|
|
179
135
|
const realBase = realpathSync(baseDir)
|
|
@@ -191,8 +147,7 @@ export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
|
191
147
|
console.warn(`[loader.bash] Missing file: ${relPath}`)
|
|
192
148
|
return null
|
|
193
149
|
}
|
|
194
|
-
// A slash
|
|
195
|
-
// readFile then throws EISDIR. Skip it rather than aborting the walk.
|
|
150
|
+
// A slash ref can resolve to a directory (`source ./lib`) → EISDIR; skip, don't abort.
|
|
196
151
|
if (err.code === 'EISDIR') {
|
|
197
152
|
console.warn(`[loader.bash] Skipping directory reference: ${relPath}`)
|
|
198
153
|
return null
|
|
@@ -218,9 +173,8 @@ export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
|
218
173
|
return sources
|
|
219
174
|
}
|
|
220
175
|
|
|
221
|
-
// Reject absolute
|
|
222
|
-
//
|
|
223
|
-
// files outside the listing's own directory.
|
|
176
|
+
// Reject absolute and `..`-escaping paths in a `.sh.txt` listing so it can't
|
|
177
|
+
// read files outside the listing's own directory.
|
|
224
178
|
function assertWithinBase(baseDir, candidate, label) {
|
|
225
179
|
if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
|
|
226
180
|
const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
|
|
@@ -229,11 +183,9 @@ function assertWithinBase(baseDir, candidate, label) {
|
|
|
229
183
|
}
|
|
230
184
|
}
|
|
231
185
|
|
|
232
|
-
// High-level:
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// sourcing is too dynamic to walk reliably — so exactly the listed files are
|
|
236
|
-
// read and resolutions are computed among them. Returns { sources, resolutions, missing }.
|
|
186
|
+
// High-level entry: reads the scripts named in a `.sh.txt` listing (relative to
|
|
187
|
+
// it). Unlike loadRust, the listing is authoritative — bash sourcing is too
|
|
188
|
+
// dynamic to walk — so exactly the listed files are read, resolutions computed among them.
|
|
237
189
|
export async function loadBash(shTxtFile) {
|
|
238
190
|
const baseDir = dirname(resolve(shTxtFile))
|
|
239
191
|
const listing = await readFile(shTxtFile, 'utf8')
|