@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/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 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)`).
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
- // 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.
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 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.
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 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.
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 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`.
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 (null); normalize undefined to null too
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
- return { scope: artifact.config?.scope ?? 'full', modules }
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 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.
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. 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.
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 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.
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 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.
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 (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.
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
- const { modules, files, imports } = diff
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. `~` reads too close to `-` in a terminal, so a changed/redirected
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'` — conditions are folded away (see resolutionsOf), so an
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 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.
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 *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.
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) {
@@ -1,19 +1,12 @@
1
- // stasis's `--mock` run-time loader entry (used by `stasis run --mock`). The
2
- // import order is load-bearing:
3
- // 1. the @exodus/stasis-core hooks lib -- evaluating it snapshots stasis's own
4
- // node:fs/path/etc. bindings off the *real* builtins (state.js destructures
5
- // them at module-eval time), so stasis keeps reading/writing through real fs;
6
- // 2. ./mock.js -- which neutralizes the network/timer builtins and fs writers in
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 only
15
- // to satisfy the no-unassigned-import lint rule; ESM still evaluates it here, in
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()
@@ -1,13 +1,8 @@
1
- // Bash loader: produces a `{ sources, resolutions, missing }` triple from a
2
- // set of shell scripts, mirroring the Solidity loader's interface (see
3
- // ./solidity.js). A reference to a .sh/.bash file by a path that lands INSIDE
4
- // the bundle root (`./lib.sh`, `dir/lib.sh`) must resolve to an in-set script;
5
- // when it doesn't it lands in `missing` so the bundle builder can reject the
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 `` ` ``, `(`, `)` (on top of whitespace, quotes,
19
- // `;`, `&`, `|`, `#`) so a reference inside a command substitution —
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`, `sh script.sh`, `/usr/bin/env 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
- // (optionally alongside other directives, e.g. `# shellcheck disable=SC1091 source=lib/x.sh`).
38
- // This is the standard, explicit way to tell tooling where a dynamically
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 every file-like reference from a script's text. Variable
46
- // expansions (`$VAR`, `${VAR}`, `$HOME/x.sh`) and flags (`-c`) are dropped
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
- // checked against `knownFiles` (tree mode, used once every script is in
65
- // hand) or the filesystem under `baseDir` (walk mode, while discovering the
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, validated.
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, validation deferred to the caller.
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
- // A `.sh`/`.bash` extension marks a reference as a shell script (as opposed to
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 { sources, resolutions, missing } triple from a Map of
141
- // already-loaded scripts. A reference is recorded in `missing` (which the
142
- // bundle builder treats as fatal) only when it resolves to a path INSIDE the
143
- // bundle root that wasn't collected — i.e. a dangling `source ./lib.sh`.
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
- // In tree mode the slash branch returns a computed in-bundle path
156
- // without checking existence, so a non-null target that isn't a known
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 the filesystem from `entries`, following resolved source/exec/direct
172
- // references and reading each reachable script exactly once. Files in the
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-bearing ref can resolve to a directory (e.g. `source ./lib`);
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 paths and `..`-escaping paths in a `.sh.txt` listing so a
222
- // malicious or sloppy listing can't trick the loader into reading arbitrary
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: takes a `.sh.txt` listing file whose lines are the scripts to
233
- // load (`.sh`, `.bash`, or extensionless), resolved relative to the listing.
234
- // Unlike loadSolidity, the listing is the authoritative manifest — bash
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')