@exodus/stasis 1.0.0-beta.0 → 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +389 -214
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
package/src/sbom.js CHANGED
@@ -1,33 +1,23 @@
1
- import semver from './apis/npm/semver.cjs'
1
+ import { compareVersions, valid } from '@preventive/upstream/semver.js'
2
2
  import pkg from '../package.json' with { type: 'json' }
3
3
 
4
- // `@exodus/stasis/sbom` — derive an SPDX 2.3 or CycloneDX 1.5 Software Bill of
5
- // Materials from already-parsed stasis artifacts (`Bundle`/`Lockfile`
6
- // instances). This module is deliberately free of file/transport concerns: it
7
- // never reads disk and never touches brotli (`node:zlib`), so it can run
8
- // wherever an artifact instance can be handed to it. The CLI glue that reads
9
- // `stasis.code.br`/`stasis.lock.json` off disk (and so pulls in brotli) lives
10
- // in `src/cmd/sbom.js`.
4
+ // Derive an SPDX 2.3 or CycloneDX 1.5 SBOM from already-parsed stasis artifacts (`Bundle`/`Lockfile`).
5
+ // Deliberately free of file/transport concerns (no disk, no brotli); the disk-reading CLI glue is src/cmd/sbom.js.
11
6
 
12
- // Identity stamped into every generated SBOM's tool/creator metadata. The tool
13
- // name is the published package name, per the command's spec.
7
+ // Tool/creator identity stamped into every generated SBOM.
14
8
  const DEFAULT_TOOL = { name: '@exodus/stasis', version: pkg.version, vendor: 'Exodus Movement, Inc.' }
15
9
 
16
- // Per-call UUID/timestamp without importing node:crypto — Web Crypto is global
17
- // in modern Node, keeping this module dependency-light.
10
+ // Web Crypto (global) instead of node:crypto, keeping this module dependency-light.
18
11
  const newUuid = () => globalThis.crypto.randomUUID()
19
12
 
20
- // purl `type`s we know how to mint. A dependency whose recorded ecosystem has
21
- // no registered purl type (`soldeer`) gets no purl — better than a fabricated
22
- // one — while npm/composer/cargo/github all map to official types.
13
+ // purl types we can mint; an ecosystem with no registered type (`soldeer`) gets no purl rather than a fabricated one,
14
+ // and so does a crate vendored from git or from a source not known (`cargo-git`, `cargo-unknown`): `pkg:cargo` names
15
+ // crates.io's crate of that name, which it may not be.
23
16
  const PURL_TYPES = new Set(['npm', 'composer', 'cargo', 'github'])
24
17
 
25
- // Infer a whole artifact's ecosystem from its loader formats. Used for the
26
- // first-party/workspace buckets (which never carry the per-dependency
27
- // `ecosystem` field) and as the fallback for older artifacts that predate the
28
- // field entirely. PHP bundles tag files with the `php` format and map to
29
- // Composer; everything else (JS/TS, and the Solidity/Rust/Bash source bundles,
30
- // which all bucket by `package.json`) uses npm-style names.
18
+ // Infer an artifact's ecosystem from its loader formats: PHP bundles map to Composer, everything
19
+ // else to npm. Used for untagged (first-party) buckets and as the fallback for legacy artifacts
20
+ // predating the per-dependency `ecosystem` field.
31
21
  function detectEcosystem(artifact) {
32
22
  const formats = artifact.formats
33
23
  if (formats && typeof formats.values === 'function') {
@@ -38,19 +28,17 @@ function detectEcosystem(artifact) {
38
28
  return 'npm'
39
29
  }
40
30
 
41
- // Legacy scope classification for artifacts predating the per-dependency
42
- // `ecosystem` field: the "." bucket and other non-`node_modules` dirs are
43
- // first-party, except in a Composer bundle where vendor packages live outside
44
- // `node_modules`.
31
+ // Legacy scope classification (pre-`ecosystem` artifacts): non-`node_modules` dirs are first-party,
32
+ // except in a Composer bundle where vendor packages live outside `node_modules`. `node_modules` is
33
+ // a path segment, as stasis-core's hasNodeModulesSegment has it: `foo_node_modules/x` is first-party.
45
34
  function classifyScope(ecosystem, dir) {
46
35
  if (dir === '.') return 'workspace'
47
36
  if (ecosystem === 'composer') return 'dependency'
48
- return dir.includes('node_modules') ? 'dependency' : 'workspace'
37
+ return dir.split('/').includes('node_modules') ? 'dependency' : 'workspace'
49
38
  }
50
39
 
51
- // Split a package name into its purl/CycloneDX namespace (group) and bare name.
52
- // npm scopes (`@scope/name`), Composer vendors and GitHub owners (`a/b`) all use
53
- // the segment before the first slash; cargo crates and bare names have none.
40
+ // Split a package name into purl/CycloneDX namespace (group) + bare name: npm scopes, Composer
41
+ // vendors and GitHub owners use the segment before the first slash; cargo and bare names have none.
54
42
  function splitName(ecosystem, name) {
55
43
  const namespaced = ecosystem === 'composer' || ecosystem === 'github' ||
56
44
  (ecosystem === 'npm' && name.startsWith('@'))
@@ -61,12 +49,11 @@ function splitName(ecosystem, name) {
61
49
  return { group: undefined, name }
62
50
  }
63
51
 
64
- // Build a Package URL (purl) — the cross-format component identifier SPDX and
65
- // CycloneDX both understand — for a package in a known ecosystem, or null when
66
- // the ecosystem has no purl type. Each path segment is percent-encoded (so an
67
- // npm scope's `@` becomes `%40`), while the `/` namespace separator and the
68
- // `@` before the version stay literal, per the purl spec. Composer and GitHub
69
- // names are case-insensitive, so they're lowercased as the spec requires.
52
+ // Build a Package URL (purl) for a known ecosystem, or null when it has no purl type. Each path
53
+ // segment is percent-encoded (npm scope's `@` -> `%40`) while the `/` namespace separator and the
54
+ // `@` before the version stay literal; Composer and GitHub names are lowercased, per the purl spec.
55
+ // The version qualifier is optional in the purl spec, so a versionless workspace package mints a
56
+ // bare-name purl rather than a fabricated version.
70
57
  export function buildPurl(ecosystem, rawName, version) {
71
58
  if (!PURL_TYPES.has(ecosystem)) return null
72
59
  let { group, name } = splitName(ecosystem, rawName)
@@ -75,93 +62,97 @@ export function buildPurl(ecosystem, rawName, version) {
75
62
  name = name.toLowerCase()
76
63
  }
77
64
  const namespace = group ? `${encodeURIComponent(group)}/` : ''
78
- return `pkg:${ecosystem}/${namespace}${encodeURIComponent(name)}@${encodeURIComponent(version)}`
65
+ const at = version == null ? '' : `@${encodeURIComponent(version)}`
66
+ return `pkg:${ecosystem}/${namespace}${encodeURIComponent(name)}${at}`
79
67
  }
80
68
 
81
69
  function compareComponents(a, b) {
82
70
  if (a.name !== b.name) return a.name < b.name ? -1 : 1
83
- if (a.version !== b.version) {
84
- if (semver.valid(a.version) && semver.valid(b.version)) return semver.compare(a.version, b.version)
85
- return a.version < b.version ? -1 : 1
71
+ // A missing version sorts first, as '' (comparing raw undefined would be order-unstable).
72
+ const [av, bv] = [a.version ?? '', b.version ?? '']
73
+ if (av !== bv) {
74
+ if (valid(av) && valid(bv)) return compareVersions(av, bv)
75
+ return av < bv ? -1 : 1
86
76
  }
87
77
  return a.ecosystem < b.ecosystem ? -1 : a.ecosystem > b.ecosystem ? 1 : 0
88
78
  }
89
79
 
90
- // Collect the deduplicated, sorted set of packages recorded across one or more
91
- // already-parsed stasis artifacts (`Bundle`/`Lockfile` instances). Unlike
92
- // `stasis audit` (which inventories only installed dependencies to query the
93
- // registry), an SBOM is the *full* bill of materials, so first-party/workspace
94
- // packages are included too — tagged `scope: 'workspace'` vs. `'dependency'` so
95
- // the formatters can pick a primary component. Buckets without a name+version
96
- // (legacy v0 bundles record neither) are skipped: there is nothing to attest.
97
- //
98
- // Each dependency's `ecosystem` (npm/composer/cargo/github/soldeer) comes from
99
- // the per-bucket field newer artifacts record; older artifacts predate it, so
100
- // we fall back to inferring it from the file's loader formats and install path.
101
- // Dedup is by ecosystem+name+version; a package seen as first-party in any
102
- // artifact stays workspace regardless of argument order, so the output doesn't
103
- // depend on how the artifacts are listed.
80
+ // Collect the deduplicated, sorted set of packages across parsed artifacts. Unlike `stasis audit`
81
+ // (installed deps only), an SBOM is the *full* bill of materials, so workspace/first-party packages
82
+ // are included too, tagged scope 'workspace' vs 'dependency'. Buckets without a name are skipped,
83
+ // and so are DEPENDENCY buckets without a version (v0 partial metadata: unverifiable identity) --
84
+ // but a workspace package may legitimately omit version (private/unpublished) and is kept, since
85
+ // dropping it would silently lose the document's own first-party subject. Dedup is by
86
+ // ecosystem+name+version; a package seen as workspace in any artifact stays workspace regardless
87
+ // of argument order, so output doesn't depend on how artifacts are listed.
88
+ // A dependency's `vendored` packages (those copied into it whose files a bundle carries) ride on its
89
+ // component as `vendored`, sorted by `dir`: each its `name`, the `version` its package.json gives if
90
+ // any, and a purl only beside one -- a vendored package.json without a version names no release, and
91
+ // may not name a registry package at all (Next.js calls its React copy `react-builtin`).
104
92
  export function collectComponents(artifacts) {
105
93
  const seen = new Map()
106
94
  for (const artifact of artifacts) {
107
95
  const records = [...artifact.modules]
108
- // The per-dependency `ecosystem` field cleanly separates deps (tagged) from
109
- // first-party buckets (untagged). If no bucket carries it, the artifact
110
- // predates the field and we use the format/path heuristic for everything.
96
+ // The per-dependency `ecosystem` field separates deps (tagged) from first-party buckets (untagged);
97
+ // if no bucket carries it, the artifact predates the field and we use the heuristic for everything.
111
98
  const tagged = records.some(([, m]) => m.ecosystem !== undefined)
112
99
  const fileEcosystem = detectEcosystem(artifact)
113
- for (const [dir, { name, version, ecosystem }] of records) {
114
- if (!name || !version) continue
100
+ for (const [dir, { name, version, ecosystem, vendored }] of records) {
101
+ if (!name) continue
115
102
  let scope, eco
116
103
  if (ecosystem !== undefined) {
117
104
  scope = 'dependency'
118
105
  eco = ecosystem
119
106
  } else if (tagged) {
120
- // Untagged bucket in a modern artifact: first-party workspace code. Its
121
- // purl ecosystem is inferred (npm, or composer for a PHP project).
107
+ // Untagged bucket in a modern artifact: first-party workspace code; ecosystem inferred.
122
108
  scope = 'workspace'
123
109
  eco = fileEcosystem
124
110
  } else {
125
111
  scope = classifyScope(fileEcosystem, dir)
126
112
  eco = fileEcosystem
127
113
  }
128
- const key = `${eco} ${name} ${version}`
129
- const existing = seen.get(key)
130
- if (existing) {
131
- if (scope === 'workspace') existing.scope = 'workspace'
132
- continue
114
+ if (!version && scope !== 'workspace') continue
115
+ const key = `${eco} ${name} ${version ?? ''}`
116
+ let component = seen.get(key)
117
+ if (component) {
118
+ if (scope === 'workspace') component.scope = 'workspace'
119
+ } else {
120
+ seen.set(key, component = { name, version: version ?? undefined, scope, ecosystem: eco, purl: buildPurl(eco, name, version) })
121
+ }
122
+ // Across artifacts, the union by directory; the first to list one names its package.
123
+ for (const [sub, v] of Object.entries(vendored ?? {})) {
124
+ component.vendored ??= new Map()
125
+ if (component.vendored.has(sub)) continue
126
+ component.vendored.set(sub, { dir: sub, name: v.name, version: v.version, ecosystem: eco, purl: v.version === undefined ? null : buildPurl(eco, v.name, v.version) })
133
127
  }
134
- seen.set(key, { name, version, scope, ecosystem: eco, purl: buildPurl(eco, name, version) })
135
128
  }
136
129
  }
130
+ for (const component of seen.values()) {
131
+ if (component.vendored) component.vendored = [...component.vendored.values()].toSorted((a, b) => (a.dir < b.dir ? -1 : 1))
132
+ }
137
133
  return [...seen.values()].toSorted(compareComponents)
138
134
  }
139
135
 
140
- // Pick the single component the SBOM is *about* (its primary/root). That is the
141
- // lone workspace package when there is exactly one; with zero or several
142
- // workspace roots (a node_modules-only artifact, or several inputs/monorepo
143
- // packages) the BOM has no single subject and every package is listed as a
144
- // plain component. `rest` is everything that is not the primary.
136
+ // Pick the single component the SBOM is *about* (its primary/root): the lone workspace package when
137
+ // there is exactly one; with zero or several workspace roots there's no single subject, so every
138
+ // package is a plain component. `rest` is everything that is not the primary.
145
139
  function selectPrimary(components) {
146
140
  const workspace = components.filter((c) => c.scope === 'workspace')
147
141
  const primary = workspace.length === 1 ? workspace[0] : null
148
142
  return { primary, rest: primary ? components.filter((c) => c !== primary) : components }
149
143
  }
150
144
 
151
- // SBOM timestamps are RFC 3339 / ISO 8601 to whole seconds (SPDX's canonical
152
- // `YYYY-MM-DDThh:mm:ssZ`); drop the milliseconds Date.toISOString includes.
145
+ // SBOM timestamps are RFC 3339 to whole seconds; drop the milliseconds Date.toISOString includes.
153
146
  const isoSeconds = (date) => date.toISOString().replace(/\.\d{3}Z$/u, 'Z')
154
147
 
155
- // A CycloneDX bom-ref must uniquely identify a component within the document;
156
- // the purl does when present, and ecosystem+name+version is unique by
157
- // construction (it is the dedup key) otherwise.
158
- const bomRef = (c) => c.purl ?? `${c.ecosystem}:${c.name}@${c.version}`
148
+ // A CycloneDX bom-ref must uniquely identify a component; use the purl when present, else
149
+ // ecosystem+name(+version) (unique by construction -- it's the dedup key).
150
+ const bomRef = (c) => c.purl ?? `${c.ecosystem}:${c.name}${c.version == null ? '' : `@${c.version}`}`
159
151
 
160
- // Render an SPDX 2.3 document (as a plain object) for the collected components.
161
- // The document DESCRIBES its primary component and the primary DEPENDS_ON each
162
- // installed dependency (a flat graph — every package in a lockfile/bundle is a
163
- // transitive dependency of the root). With no single primary the document
164
- // DESCRIBES the workspace packages, or every package when there are none.
152
+ // Render an SPDX 2.3 document. It DESCRIBES its primary component, which DEPENDS_ON each installed
153
+ // dependency (flat graph). With no single primary, the document DESCRIBES the workspace packages,
154
+ // or every package when there are none. A package vendored into a dependency is a package of its own,
155
+ // after the rest, which that dependency CONTAINS, its directory there its `packageFileName`.
165
156
  export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid = newUuid() } = {}) {
166
157
  const { primary, rest } = selectPrimary(components)
167
158
  const ordered = primary ? [primary, ...rest] : components
@@ -170,7 +161,8 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
170
161
  const packages = ordered.map((c) => ({
171
162
  SPDXID: ids.get(c),
172
163
  name: c.name,
173
- versionInfo: c.version,
164
+ // SPDX versionInfo is optional; omit it for a versionless workspace package.
165
+ ...(c.version == null ? {} : { versionInfo: c.version }),
174
166
  downloadLocation: 'NOASSERTION',
175
167
  filesAnalyzed: false,
176
168
  ...(c.purl && {
@@ -178,6 +170,21 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
178
170
  }),
179
171
  primaryPackagePurpose: c.scope === 'workspace' ? 'APPLICATION' : 'LIBRARY',
180
172
  }))
173
+ const vendored = ordered.flatMap((c) => (c.vendored ?? []).map((v, i) => ({ host: c, v, id: `${ids.get(c)}-Vendored-${i}` })))
174
+ for (const { v, id } of vendored) {
175
+ packages.push({
176
+ SPDXID: id,
177
+ name: v.name,
178
+ ...(v.version == null ? {} : { versionInfo: v.version }),
179
+ packageFileName: v.dir,
180
+ downloadLocation: 'NOASSERTION',
181
+ filesAnalyzed: false,
182
+ ...(v.purl && {
183
+ externalRefs: [{ referenceCategory: 'PACKAGE-MANAGER', referenceType: 'purl', referenceLocator: v.purl }],
184
+ }),
185
+ primaryPackagePurpose: 'LIBRARY',
186
+ })
187
+ }
181
188
 
182
189
  const relationships = []
183
190
  if (primary) {
@@ -193,8 +200,13 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
193
200
  relationships.push({ spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElement: ids.get(c), relationshipType: 'DESCRIBES' })
194
201
  }
195
202
  }
203
+ for (const { host, id } of vendored) {
204
+ relationships.push({ spdxElementId: ids.get(host), relatedSpdxElement: id, relationshipType: 'CONTAINS' })
205
+ }
196
206
 
197
- const docName = primary ? `${primary.name}@${primary.version}` : 'stasis-sbom'
207
+ const docName = primary
208
+ ? (primary.version == null ? primary.name : `${primary.name}@${primary.version}`)
209
+ : 'stasis-sbom'
198
210
  return {
199
211
  spdxVersion: 'SPDX-2.3',
200
212
  dataLicense: 'CC0-1.0',
@@ -210,10 +222,10 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
210
222
  }
211
223
  }
212
224
 
213
- // Render a CycloneDX 1.5 document (as a plain object) for the collected
214
- // components. The primary (workspace root) becomes `metadata.component`; the
215
- // rest are listed in `components`, with a flat `dependencies` edge from the
216
- // primary to each installed dependency.
225
+ // Render a CycloneDX 1.5 document. The primary (workspace root) becomes metadata.component; the
226
+ // rest go in `components`, with a flat `dependencies` edge from primary to each installed dependency.
227
+ // A package vendored into a dependency is nested in that dependency's `components`, its directory
228
+ // there the location of its one occurrence.
217
229
  export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid = newUuid() } = {}) {
218
230
  const { primary, rest } = selectPrimary(components)
219
231
  const toComponent = (c) => {
@@ -223,8 +235,24 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
223
235
  'bom-ref': bomRef(c),
224
236
  ...(group && { group }),
225
237
  name,
226
- version: c.version,
238
+ // CycloneDX 1.5 `version` is optional; omit it for a versionless workspace package.
239
+ ...(c.version == null ? {} : { version: c.version }),
227
240
  ...(c.purl && { purl: c.purl }),
241
+ ...(c.vendored && { components: c.vendored.map((v) => toVendoredComponent(c, v)) }),
242
+ }
243
+ }
244
+ // Its bom-ref the host's with the directory as a purl subpath: two copies of one package (Next.js
245
+ // has loader-utils2 and loader-utils3, both `loader-utils`) stay apart.
246
+ const toVendoredComponent = (host, v) => {
247
+ const { group, name } = splitName(v.ecosystem, v.name)
248
+ return {
249
+ type: 'library',
250
+ 'bom-ref': `${bomRef(host)}#${v.dir}`,
251
+ ...(group && { group }),
252
+ name,
253
+ ...(v.version == null ? {} : { version: v.version }),
254
+ ...(v.purl && { purl: v.purl }),
255
+ evidence: { occurrences: [{ location: v.dir }] },
228
256
  }
229
257
  }
230
258
 
@@ -235,8 +263,7 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
235
263
  version: 1,
236
264
  metadata: {
237
265
  timestamp: isoSeconds(now),
238
- // CycloneDX 1.5 `tools.components` shape: the flat tools array is
239
- // deprecated in 1.5 (removed in 1.6), and newer consumers read this form.
266
+ // CycloneDX 1.5 `tools.components` shape: the flat tools array is deprecated in 1.5 (removed in 1.6).
240
267
  tools: { components: [{ type: 'application', publisher: tool.vendor, name: tool.name, version: tool.version }] },
241
268
  ...(primary && { component: toComponent(primary) }),
242
269
  },
@@ -249,18 +276,14 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
249
276
  return doc
250
277
  }
251
278
 
252
- // Dispatch to the requested format. `components` is the output of
253
- // `collectComponents`; `options` accepts `{ tool, now, uuid }` for custom
254
- // tool identity / deterministic output.
279
+ // Dispatch to the requested format; options: { tool, now, uuid } for custom identity / deterministic output.
255
280
  export function generateSbom(format, components, options) {
256
281
  if (format === 'spdx') return toSpdx(components, options)
257
282
  if (format === 'cyclonedx') return toCyclonedx(components, options)
258
283
  throw new Error(`sbom: format must be spdx or cyclonedx, got ${format}`)
259
284
  }
260
285
 
261
- // Convenience one-shot: collect the components from already-parsed artifacts and
262
- // render them in `format`. Equivalent to
263
- // `generateSbom(format, collectComponents(artifacts), options)`.
286
+ // Convenience one-shot: collectComponents then generateSbom.
264
287
  export function sbom(format, artifacts, options) {
265
288
  return generateSbom(format, collectComponents(artifacts), options)
266
289
  }