From 29e5c0731a08edf80fde8478d583631ba16cb899 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 12:11:56 +0000 Subject: [PATCH 1/2] fix(sbom): sort components by purl before export Direct scan rows were emitted ahead of the sorted lockfile-only list, so CycloneDX components, dependency entries, and SPDX packages followed scan and filesystem order. Sort the full list by Package URL when one is written, otherwise by name, then version, then ecosystem, and apply the same order to graph CycloneDX and SPDX export. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 2 + DOCS.md | 34 +++--- docs/sbom-dependency-scope.md | 14 +-- src/engine/export.test.ts | 19 ++++ src/engine/export.ts | 37 ++++++- src/reporting/commands/sbom.test.ts | 27 ++--- src/reporting/commands/sbom.ts | 45 +++++++- test/sbom-component-order.test.ts | 160 +++++++++++++++++++++++++++ test/sbom-duplicate-versions.test.ts | 52 +++++---- test/sbom-lockfile-merge.test.ts | 6 +- 10 files changed, 325 insertions(+), 71 deletions(-) create mode 100644 test/sbom-component-order.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 034675d6..7074e900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ backward compatible. ### Fixed +- **SBOM component order no longer follows scan or filesystem order.** `vg sbom export` and CycloneDX/SPDX graph export sort components by Package URL when one is written, otherwise by name, then version, then ecosystem, before the JSON is serialized. CycloneDX `dependencies` (after the root entry) and SPDX `packages` use that order, and `SPDXRef-Package-N` follows it. The same scan still produces the same document on every run. Reordering projects still changes which project's metadata is kept, and therefore the document id, but it does not change component order. + - **`vg report --format` rejects values other than `md`, `text`, and `json`.** An unknown value, including `html`, exits `5` with a usage error that names the value and lists the valid ones. Stdout is empty. `text` stays the diff --git a/DOCS.md b/DOCS.md index 7f9fb200..28cdcfe3 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1761,9 +1761,9 @@ Two other strings in the same file are easy to misread as package digests. `vcs. | `uv.lock` | `hash` | | `go.sum` | `h1:` | -**Several digests, one component.** A lockfile can list more than one digest for one package. The export still writes one component for that ecosystem, name, and version. It does not add a row per digest. `hashes` and `checksums` are omitted, so there is no digest array and no digest order to keep stable. Component order stays the order in [Several versions of one package](#several-versions-of-one-package): direct rows follow the scan artifact, then lockfile-only rows sorted by package name, then version, then ecosystem. Digest text is not part of that sort, and it is not part of the document id. Exporting the same scan artifact again, after a lockfile edit that changes only those digest strings and leaves names, versions, and edges alone, writes the same JSON, including `serialNumber` and `documentNamespace`. +**Several digests, one component.** A lockfile can list more than one digest for one package. The export still writes one component for that ecosystem, name, and version. It does not add a row per digest. `hashes` and `checksums` are omitted, so there is no digest array and no digest order to keep stable. Component order stays the order in [Several versions of one package](#several-versions-of-one-package): the Package URL when one is written, otherwise the package name, then the version, then the ecosystem. Digest text is not part of that sort, and it is not part of the document id. Exporting the same scan artifact again, after a lockfile edit that changes only those digest strings and leaves names, versions, and edges alone, writes the same JSON, including `serialNumber` and `documentNamespace`. -`go.sum` lists a module twice: ` h1:…` and ` /go.mod h1:…`. The `/go.mod` line is not a second component. Both `h1:` values are dropped. A `uv.lock` package block can carry more than one `hash`. Those values are dropped, and the block stays one component, sorted with the others by name and version. An npm `integrity` string and a pnpm `resolution.integrity` string are not read, including when the string names more than one algorithm. +`go.sum` lists a module twice: ` h1:…` and ` /go.mod h1:…`. The `/go.mod` line is not a second component. Both `h1:` values are dropped. A `uv.lock` package block can carry more than one `hash`. Those values are dropped, and the block stays one component, placed with the others by the order in [Several versions of one package](#several-versions-of-one-package). An npm `integrity` string and a pnpm `resolution.integrity` string are not read, including when the string names more than one algorithm. ```bash vg scan --offline --no-graph --format json --out scan.json @@ -1880,12 +1880,15 @@ absent, and so is the dependency graph. A consumer that filters to `vibgrate:scope=direct` sees the same gap: other installed versions of that package are still in the full document, marked `transitive`. -**Order.** Direct rows follow `projects` on the scan artifact, and within a -project they follow that project's `dependencies` array. The npm scanner sorts -each project's array by drift, then by package name, before it writes the -artifact. Lockfile-only rows are appended after the direct rows, sorted by -package name, then by version, then by ecosystem. That combined list is the order of CycloneDX -`components`, CycloneDX `dependencies`, and SPDX `packages`. `dependsOn` entries +**Order.** Before the JSON is written, every component is sorted by its +Package URL when one is written, otherwise by package name, then by version, +then by ecosystem. The comparison is UTF-16 code unit order, so it does not +follow the locale, the order of `projects` on the scan artifact, the order of +a project's `dependencies` array, or the order a directory walk returned +those files. Direct rows and lockfile-only rows are one list. That list is +the order of CycloneDX `components`, the component entries of CycloneDX +`dependencies` (the first entry stays `vibgrate-root`), and SPDX `packages`. +`SPDXRef-Package-1` is the first package in that list. `dependsOn` entries and the names inside one lockfile edge are sorted on their own. **Same inputs, same document.** For one scan artifact and the lockfiles under @@ -1901,13 +1904,14 @@ are in [Package digests](#package-digests). **Known limitations:** -- Direct-row order, and which project's metadata is kept for a shared - identity, follow the scan artifact. Reordering projects changes - `vibgrate:project` on that row, reassigns SPDX `SPDXID` values to match the - new positions, retargets SPDX `DEPENDS_ON` relationships (they point at - SPDX IDs), and changes the document serial number and namespace. CycloneDX `bom-ref` stays on the purl, - so a scanner that stored the purl still matches. `vibgrate:projects` stays - the sorted set of contributing projects. +- Which project's metadata is kept for a shared identity follows the scan + artifact. Reordering projects changes `vibgrate:project` on that row and + changes the document serial number and namespace, because the kept project + is part of the document id. Component order does not follow that project + order. SPDX `SPDXID` values and the `DEPENDS_ON` relationships that point + at them stay with the purl sort above. CycloneDX `bom-ref` stays on the + purl, so a scanner that stored the purl still matches. `vibgrate:projects` + stays the sorted set of contributing projects. - npm `package-lock.json` v2/v3 collapses two install paths of the same `name@version` into one component. When those paths declare different dependencies, the edge list is the path that appears last in the lockfile diff --git a/docs/sbom-dependency-scope.md b/docs/sbom-dependency-scope.md index 625275c1..ede060dc 100644 --- a/docs/sbom-dependency-scope.md +++ b/docs/sbom-dependency-scope.md @@ -78,16 +78,16 @@ scope-fixture └── dev-optional-pkg@1.2.3 lockfile flags dev, optional, and devOptional ``` -Component order follows the scan's dependency array (drift, then package name) and then lockfile-only rows (package name, then version). With every row at drift `unknown`, the names sort alphabetically inside each group. +Component order is the Package URL when one is written, otherwise the package name, then the version, then the ecosystem. Every row in this fixture has a purl, so the list is that purl order. It is not the scan's dependency array, and lockfile-only rows are not a second group after the direct rows. | Package | `vibgrate:scope` | CycloneDX component `scope` | Root `dependsOn` | SPDX annotation | SPDX relationship | | --- | --- | --- | --- | --- | --- | -| `fsevents@2.3.3` | `direct` | omitted | yes (`pkg:npm/fsevents@2.3.3`) | `scope=direct` on `SPDXRef-Package-1` | `DEPENDS_ON` from `SPDXRef-DOCUMENT` | -| `left-pad@1.3.0` | `direct` | omitted | yes | `scope=direct` on `SPDXRef-Package-2` | `DEPENDS_ON` from `SPDXRef-DOCUMENT`. This package `DEPENDS_ON` `SPDXRef-Package-6` (`nested-prod`) | -| `typescript@5.4.5` | `direct` | omitted | no | `scope=direct` on `SPDXRef-Package-3` | No document relationship. This package `DEPENDS_ON` `SPDXRef-Package-5` (`nested-dev`) | -| `dev-optional-pkg@1.2.3` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-4` | none | -| `nested-dev@1.0.0` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-5` | target of `typescript` only | -| `nested-prod@1.0.0` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-6` | target of `left-pad` only | +| `dev-optional-pkg@1.2.3` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-1` | none | +| `fsevents@2.3.3` | `direct` | omitted | yes (`pkg:npm/fsevents@2.3.3`) | `scope=direct` on `SPDXRef-Package-2` | `DEPENDS_ON` from `SPDXRef-DOCUMENT` | +| `left-pad@1.3.0` | `direct` | omitted | yes | `scope=direct` on `SPDXRef-Package-3` | `DEPENDS_ON` from `SPDXRef-DOCUMENT`. This package `DEPENDS_ON` `SPDXRef-Package-5` (`nested-prod`) | +| `nested-dev@1.0.0` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-4` | target of `typescript` only | +| `nested-prod@1.0.0` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-5` | target of `left-pad` only | +| `typescript@5.4.5` | `direct` | omitted | no | `scope=direct` on `SPDXRef-Package-6` | No document relationship. This package `DEPENDS_ON` `SPDXRef-Package-4` (`nested-dev`) | A CycloneDX component for `typescript` has `type`, `bom-ref`, `name`, `version`, `purl`, and `properties`. The component `scope` member is absent. `properties` includes `vibgrate:scope` = `direct`. The root `dependencies` entry (`bom-ref` `vibgrate-root`) lists `fsevents` and `left-pad`. `typescript` has its own `dependsOn` entry pointing at `nested-dev`. diff --git a/src/engine/export.test.ts b/src/engine/export.test.ts index 499dba56..b5167df1 100644 --- a/src/engine/export.test.ts +++ b/src/engine/export.test.ts @@ -148,4 +148,23 @@ describe('cyclonedx export purl', () => { // Non-npm rows still carry no guessed npm purl. expect(bom.components.find((c) => c.name === 'requests')?.purl).toBeUndefined(); }); + + it('sorts components by purl, then name, version, and ecosystem, independent of input order', () => { + const base = ctx(makeGraph(false)); + const chalk = { name: 'chalk', ecosystem: 'npm' as const, declared: '^5.0.0', installed: '5.3.0' }; + const spaced = { name: 'foo bar', ecosystem: 'npm' as const, declared: '1.0.0', installed: '1.0.0' }; + const requests = { name: 'requests', ecosystem: 'pypi' as const, declared: '2.31.0', installed: '2.31.0' }; + const alpha = { runtime: 'ollama' as const, name: 'alpha', path: '/a' }; + const zeta = { runtime: 'gguf' as const, name: 'zeta', path: '/z' }; + const first = exportGraph('cyclonedx', { ...base, deps: [chalk, spaced, requests], models: [zeta, alpha] }); + const second = exportGraph('cyclonedx', { ...base, deps: [requests, spaced, chalk], models: [alpha, zeta] }); + expect(second).toBe(first); + const bom = JSON.parse(first) as { components: Array<{ name: string; type: string }> }; + expect(bom.components.map((c) => c.name)).toEqual(['foo bar', 'chalk', 'requests', 'alpha', 'zeta']); + const spdxFirst = exportGraph('spdx', { ...base, deps: [chalk, spaced, requests] }); + const spdxSecond = exportGraph('spdx', { ...base, deps: [requests, chalk, spaced] }); + expect(spdxSecond).toBe(spdxFirst); + const doc = JSON.parse(spdxFirst) as { packages: Array<{ name: string }> }; + expect(doc.packages.map((p) => p.name)).toEqual(['foo bar', 'chalk', 'requests']); + }); }); diff --git a/src/engine/export.ts b/src/engine/export.ts index f6ac8d51..1dcea2b1 100644 --- a/src/engine/export.ts +++ b/src/engine/export.ts @@ -2,7 +2,7 @@ import { serializeGraph, slimGraphForExport } from './serialize.js'; import { renderReport } from './report.js'; import { renderHtml } from './html.js'; import type { DepRecord } from './drift.js'; -import { resolvePurl } from '../reporting/commands/sbom.js'; +import { compareSbomComponentOrder, resolvePurl } from '../reporting/commands/sbom.js'; import type { LocalModel } from './models.js'; import type { VgGraph } from '../schema.js'; @@ -242,12 +242,37 @@ function sqlBool(v: boolean | null | undefined): string { return v == null ? 'NULL' : v ? '1' : '0'; } +/** Package URL this exporter actually writes for a dependency, or null when it omits one. */ +function emittedDepPurl(dep: DepRecord): string | null { + if (dep.ecosystem !== 'npm') return null; + return resolvePurl('npm', dep.name, dep.installed ?? '').purl; +} + +function sortExportDeps(deps: DepRecord[]): DepRecord[] { + return [...deps].sort((a, b) => + compareSbomComponentOrder( + { purl: emittedDepPurl(a), name: a.name, version: a.installed ?? a.declared, ecosystem: a.ecosystem }, + { purl: emittedDepPurl(b), name: b.name, version: b.installed ?? b.declared, ecosystem: b.ecosystem }, + ), + ); +} + +function sortExportModels(models: LocalModel[]): LocalModel[] { + return [...models].sort((a, b) => + compareSbomComponentOrder( + { purl: null, name: a.name, version: '', ecosystem: a.runtime }, + { purl: null, name: b.name, version: '', ecosystem: b.runtime }, + ), + ); +} + function cyclonedx(ctx: ExportContext): string { // CycloneDX 1.6 JSON — dependencies as library components + local models as - // machine-learning-model components (AI-BOM). Deterministic ordering; no - // timestamps beyond the pinned generatedAt. + // machine-learning-model components (AI-BOM). Library components are sorted + // before serialize (purl, else name, then version, then ecosystem). Models + // follow, sorted by name then runtime. No timestamps beyond the pinned generatedAt. const components: unknown[] = []; - for (const d of ctx.deps ?? []) { + for (const d of sortExportDeps(ctx.deps ?? [])) { const version = d.installed ?? d.declared; if (d.ecosystem !== 'npm') { components.push({ type: 'library', name: d.name, version, purl: undefined }); @@ -270,7 +295,7 @@ function cyclonedx(ctx: ExportContext): string { }); } } - for (const m of ctx.models ?? []) { + for (const m of sortExportModels(ctx.models ?? [])) { components.push({ type: 'machine-learning-model', name: m.name, properties: [{ name: 'vg:runtime', value: m.runtime }] }); } const bom = { @@ -283,7 +308,7 @@ function cyclonedx(ctx: ExportContext): string { } function spdx(ctx: ExportContext): string { - const packages = (ctx.deps ?? []).map((d) => ({ + const packages = sortExportDeps(ctx.deps ?? []).map((d) => ({ SPDXID: `SPDXRef-Package-${cypherLabel(d.name)}`, name: d.name, versionInfo: d.installed ?? d.declared, diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 5e18bbe4..e7988c6a 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -90,12 +90,12 @@ describe('sbom helpers', () => { expect(cdx.metadata.component.type).toBe('application'); expect(cdx.metadata.component.version).toBeUndefined(); expect(cdx.components.map((c) => ({ type: c.type, name: c.name }))).toEqual([ - { type: 'library', name: 'chalk' }, { type: 'library', name: 'library/alpine' }, + { type: 'library', name: 'chalk' }, ]); const spdx = toSpdx(artifact) as { packages: Array> }; - expect(spdx.packages.map((pkg) => pkg.name)).toEqual(['chalk', 'library/alpine']); + expect(spdx.packages.map((pkg) => pkg.name)).toEqual(['library/alpine', 'chalk']); for (const pkg of spdx.packages) { expect(pkg).not.toHaveProperty('primaryPackagePurpose'); } @@ -122,7 +122,7 @@ describe('sbom helpers', () => { const sbom = toSpdx(makeArtifact('5.3.0', 90), componentsOnlyGraph([{ package: 'ansi-styles', version: '6.2.1' }])) as { packages: Array<{ name: string }>; }; - expect(sbom.packages.map((p) => p.name)).toEqual(['chalk', 'ansi-styles']); + expect(sbom.packages.map((p) => p.name)).toEqual(['ansi-styles', 'chalk']); }); it('gives every CycloneDX component and the root a purl-based bom-ref, and a matching purl field', () => { @@ -158,8 +158,8 @@ describe('sbom helpers', () => { }; expect(sbom.dependencies).toEqual([ { ref: 'vibgrate-root', dependsOn: ['pkg:npm/chalk@5.3.0'] }, - { ref: 'pkg:npm/chalk@5.3.0', dependsOn: ['pkg:npm/ansi-styles@6.2.1'] }, { ref: 'pkg:npm/ansi-styles@6.2.1', dependsOn: [] }, + { ref: 'pkg:npm/chalk@5.3.0', dependsOn: ['pkg:npm/ansi-styles@6.2.1'] }, ]); }); @@ -176,8 +176,8 @@ describe('sbom helpers', () => { relationships: Array<{ spdxElementId: string; relatedSpdxElementId: string; relationshipType: string }>; }; expect(sbom.relationships).toEqual([ - { spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElementId: 'SPDXRef-Package-1', relationshipType: 'DEPENDS_ON' }, - { spdxElementId: 'SPDXRef-Package-1', relatedSpdxElementId: 'SPDXRef-Package-2', relationshipType: 'DEPENDS_ON' }, + { spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElementId: 'SPDXRef-Package-2', relationshipType: 'DEPENDS_ON' }, + { spdxElementId: 'SPDXRef-Package-2', relatedSpdxElementId: 'SPDXRef-Package-1', relationshipType: 'DEPENDS_ON' }, ]); }); @@ -306,8 +306,8 @@ describe('sbom helpers', () => { expect(again).not.toContain('%40scope/'); expect(again).not.toContain('%C3%A9'); - expect(cyclone.components.map((c) => c.name)).toEqual(['chalk', 'foo bar', '@scope/', 'café']); - expect(cyclone.components[0]!.purl).toBe('pkg:npm/chalk@5.3.0'); + expect(cyclone.components.map((c) => c.name)).toEqual(['@scope/', 'café', 'foo bar', 'chalk']); + expect(cyclone.components.find((c) => c.name === 'chalk')!.purl).toBe('pkg:npm/chalk@5.3.0'); for (const name of ['foo bar', '@scope/', 'café']) { const row = cyclone.components.find((c) => c.name === name)!; @@ -324,12 +324,13 @@ describe('sbom helpers', () => { const warnings = collectPurlWarnings(artifact); expect(warnings).toEqual([ - describeUnavailablePurl('npm', 'foo bar', '1.0.0'), describeUnavailablePurl('npm', '@scope/', '2.0.0'), describeUnavailablePurl('npm', 'café', '3.0.0'), + describeUnavailablePurl('npm', 'foo bar', '1.0.0'), ]); - expect(warnings[0]).toContain('whitespace or a non-ASCII character'); - expect(warnings[1]).toContain('empty path segment'); + expect(warnings[0]).toContain('empty path segment'); + expect(warnings[1]).toContain('whitespace or a non-ASCII character'); + expect(warnings[2]).toContain('whitespace or a non-ASCII character'); const spdx = toSpdx(artifact) as { packages: Array<{ @@ -342,7 +343,7 @@ describe('sbom helpers', () => { const bad = spdx.packages.find((p) => p.name === 'foo bar')!; expect(bad.externalRefs).toBeUndefined(); expect(bad.annotations[0]!.comment).toContain('purlStatus=unavailable'); - expect(bad.annotations[1]!.comment).toBe(warnings[0]); + expect(bad.annotations[1]!.comment).toBe(describeUnavailablePurl('npm', 'foo bar', '1.0.0')); expect(spdx.packages.find((p) => p.name === 'chalk')!.externalRefs?.[0]?.referenceLocator).toBe('pkg:npm/chalk@5.3.0'); }); @@ -366,8 +367,8 @@ describe('sbom helpers', () => { expect(badRef).toBe('vibgrate:npm:foo bar@1.0.0'); expect(sbom.dependencies).toEqual([ { ref: 'vibgrate-root', dependsOn: ['pkg:npm/chalk@5.3.0', badRef] }, - { ref: 'pkg:npm/chalk@5.3.0', dependsOn: [badRef] }, { ref: badRef, dependsOn: [] }, + { ref: 'pkg:npm/chalk@5.3.0', dependsOn: [badRef] }, ]); expect(JSON.stringify(sbom.dependencies)).not.toContain('pkg:npm/foo'); }); diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index 9c112ea0..addeb409 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -413,6 +413,42 @@ function sortedUnique(names: Iterable): string[] { return [...new Set(names)].sort((a, b) => a.localeCompare(b)); } +/** Identity fields used to order SBOM components. `purl` is null when the row has none. */ +export interface SbomComponentOrderKey { + purl: string | null; + name: string; + version: string; + ecosystem: string; +} + +/** + * Order for emitted components: Package URL when one is present, otherwise + * the package name, then version, then ecosystem. Comparison is UTF-16 code + * unit order so the result does not depend on locale, scan order, or the + * order a directory walk returned files. + */ +export function compareSbomComponentOrder(a: SbomComponentOrderKey, b: SbomComponentOrderKey): number { + return ( + cmpCodePoint(a.purl ?? a.name, b.purl ?? b.name) || + cmpCodePoint(a.name, b.name) || + cmpCodePoint(a.version, b.version) || + cmpCodePoint(a.ecosystem, b.ecosystem) + ); +} + +function cmpCodePoint(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function compareFlattenedDependency(a: FlattenedDependency, b: FlattenedDependency): number { + return compareSbomComponentOrder( + { purl: purlFor(a.ecosystem, a.package, a.version), name: a.package, version: a.version, ecosystem: a.ecosystem }, + { purl: purlFor(b.ecosystem, b.package, b.version), name: b.package, version: b.version, ecosystem: b.ecosystem }, + ); +} + function addProjects(row: FlattenedDependency, names: Iterable): void { row.projects = sortedUnique([...row.projects, ...names]); } @@ -526,6 +562,10 @@ interface MergedLockfileComponent extends LockfileComponent { * first and win over a lockfile row. The same identity from another project * stays one component; every contributing project is recorded. Differing * manifest metadata is dropped with a warning, not silently. + * + * The returned list is the emit order. It is sorted by Package URL when one + * can be built, otherwise by package name, then version, then ecosystem. + * Direct rows are not left in scan order ahead of the lockfile-only rows. */ export function flattenDependencies( artifact: ScanArtifact, @@ -608,10 +648,7 @@ export function flattenDependencies( index.set(key, row); lockfileOnly.push(row); } - lockfileOnly.sort( - (a, b) => a.package.localeCompare(b.package) || a.version.localeCompare(b.version) || a.ecosystem.localeCompare(b.ecosystem), - ); - return [...rows, ...lockfileOnly]; + return [...rows, ...lockfileOnly].sort(compareFlattenedDependency); } interface LockfileMergeEntry { diff --git a/test/sbom-component-order.test.ts b/test/sbom-component-order.test.ts new file mode 100644 index 00000000..42e2ef3b --- /dev/null +++ b/test/sbom-component-order.test.ts @@ -0,0 +1,160 @@ +/** + * Component order in `vg sbom export` is the Package URL when one is written, + * otherwise the name, then the version, then the ecosystem. Two exports of the + * same fixture match, including when the scan lists projects and dependencies + * in the opposite order (the order a directory walk can return). + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { collectLockfileGraph, toCycloneDx, toSpdx } from '../src/reporting/commands/sbom.js'; +import type { LockfileGraph } from '../src/engine/lockfile.js'; +import type { DependencyRow, ProjectScan, ScanArtifact } from '../src/reporting/types.js'; + +function dep(name: string, version: string): DependencyRow { + return { + package: name, + section: 'dependencies', + currentSpec: version, + resolvedVersion: version, + latestStable: version, + majorsBehind: 0, + drift: 'current', + }; +} + +function project(name: string, relPath: string, deps: DependencyRow[]): ProjectScan { + return { + type: 'node', + path: relPath, + name, + frameworks: [], + dependencies: deps, + dependencyAgeBuckets: { current: deps.length, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }; +} + +function artifact(projects: ProjectScan[]): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp: '2026-02-19T00:00:00.000Z', + vibgrateVersion: '0.0.1', + rootPath: 'order-fixture', + drift: { + score: 10, + riskLevel: 'low', + components: { runtimeScore: 10, frameworkScore: 10, dependencyScore: 10, eolScore: 10 }, + }, + findings: [], + projects, + }; +} + +function writeLock(root: string, rel: string, body: unknown): void { + const dir = path.join(root, rel); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify(body)); +} + +/** Same packages; project and dependency arrays run in the other direction. */ +function reverseScan(scan: ScanArtifact): ScanArtifact { + return artifact( + [...scan.projects].reverse().map((projectScan) => ({ + ...projectScan, + dependencies: [...projectScan.dependencies].reverse(), + })), + ); +} + +/** Same edges and components, inserted in the opposite order. */ +function reverseGraph(graph: LockfileGraph): LockfileGraph { + const edges = graph.edges + ? new Map([...graph.edges.entries()].reverse().map(([key, children]) => [key, [...children].reverse()])) + : undefined; + return { + ...graph, + components: [...graph.components].reverse(), + edges, + rootDependsOn: [...graph.rootDependsOn].reverse(), + }; +} + +describe('sbom export: stable component order', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-sbom-order-')); + writeLock(root, 'apps/docs', { + name: 'docs', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'docs', dependencies: { once: '1.4.0' } }, + 'node_modules/once': { version: '1.4.0' }, + }, + }); + writeLock(root, 'apps/web', { + name: 'web', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'web', dependencies: { 'left-pad': '1.3.0', widget: '1.0.0' } }, + 'node_modules/left-pad': { version: '1.3.0' }, + 'node_modules/widget': { version: '1.0.0', dependencies: { 'ansi-styles': '6.2.1' } }, + 'node_modules/ansi-styles': { version: '6.2.1' }, + }, + }); + }); + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('exports the same component and dependency order twice, independent of scan order', () => { + const scan = artifact([ + project('web', 'apps/web', [dep('widget', '1.0.0'), dep('left-pad', '1.3.0'), dep('foo bar', '1.0.0')]), + project('docs', 'apps/docs', [dep('once', '1.4.0')]), + ]); + const graph = collectLockfileGraph(scan, root); + const again = collectLockfileGraph(scan, root); + expect(graph).toBeDefined(); + + const first = toCycloneDx(scan, graph); + const second = toCycloneDx(scan, again); + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + expect(JSON.stringify(toSpdx(scan, again))).toBe(JSON.stringify(toSpdx(scan, graph))); + + const reversed = toCycloneDx(reverseScan(scan), reverseGraph(graph!)); + const cdx = first as { + components: Array<{ name: string; purl?: string; 'bom-ref': string }>; + dependencies: Array<{ ref: string; dependsOn: string[] }>; + }; + const rev = reversed as typeof cdx; + const identity = (doc: typeof cdx): string[] => doc.components.map((c) => c.purl ?? c['bom-ref']); + + expect(identity(cdx)).toEqual([ + 'vibgrate:npm:foo bar@1.0.0', + 'pkg:npm/ansi-styles@6.2.1', + 'pkg:npm/left-pad@1.3.0', + 'pkg:npm/once@1.4.0', + 'pkg:npm/widget@1.0.0', + ]); + expect(identity(rev)).toEqual(identity(cdx)); + expect(cdx.dependencies.map((d) => d.ref)).toEqual(['vibgrate-root', ...identity(cdx)]); + expect(rev.dependencies).toEqual(cdx.dependencies); + + const spdx = toSpdx(scan, graph) as { + packages: Array<{ name: string; versionInfo: string; SPDXID: string }>; + relationships: Array<{ spdxElementId: string; relatedSpdxElementId: string; relationshipType: string }>; + }; + const spdxRev = toSpdx(reverseScan(scan), reverseGraph(graph!)) as typeof spdx; + expect(spdx.packages.map((p) => `${p.SPDXID} ${p.name}@${p.versionInfo}`)).toEqual([ + 'SPDXRef-Package-1 foo bar@1.0.0', + 'SPDXRef-Package-2 ansi-styles@6.2.1', + 'SPDXRef-Package-3 left-pad@1.3.0', + 'SPDXRef-Package-4 once@1.4.0', + 'SPDXRef-Package-5 widget@1.0.0', + ]); + expect(spdxRev.packages.map((p) => p.SPDXID)).toEqual(spdx.packages.map((p) => p.SPDXID)); + expect(spdxRev.relationships).toEqual(spdx.relationships); + }); +}); diff --git a/test/sbom-duplicate-versions.test.ts b/test/sbom-duplicate-versions.test.ts index 2768be70..3cf03f68 100644 --- a/test/sbom-duplicate-versions.test.ts +++ b/test/sbom-duplicate-versions.test.ts @@ -1,7 +1,8 @@ /** * Pins the documented `vg sbom export` contract for several resolved versions * of one package: identity, dedup, scope, order, and the known limitations - * (artifact order, last lockfile path wins the edges, PyPI purl folding). + * (kept-project attribution follows the scan artifact, last lockfile path + * wins the edges, PyPI purl folding). Component order follows the purl sort. */ import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -145,12 +146,12 @@ describe('sbom export: several versions of one package', () => { project: scopeOf(c.properties, 'vibgrate:project'), })); expect(rows).toEqual([ - { name: 'left-pad', version: '1.3.0', bom: 'pkg:npm/left-pad@1.3.0', purl: 'pkg:npm/left-pad@1.3.0', scope: 'direct', project: 'shared-root' }, - { name: 'widget', version: '1.0.0', bom: 'pkg:npm/widget@1.0.0', purl: 'pkg:npm/widget@1.0.0', scope: 'direct', project: 'shared-root' }, - { name: 'once', version: '1.4.0', bom: 'pkg:npm/once@1.4.0', purl: 'pkg:npm/once@1.4.0', scope: 'direct', project: 'shared-extra' }, { name: 'left-pad', version: '1.2.0', bom: 'pkg:npm/left-pad@1.2.0', purl: 'pkg:npm/left-pad@1.2.0', scope: 'transitive', project: 'shared-root' }, + { name: 'left-pad', version: '1.3.0', bom: 'pkg:npm/left-pad@1.3.0', purl: 'pkg:npm/left-pad@1.3.0', scope: 'direct', project: 'shared-root' }, { name: 'ms', version: '2.1.3', bom: 'pkg:npm/ms@2.1.3', purl: 'pkg:npm/ms@2.1.3', scope: 'transitive', project: 'shared-extra' }, { name: 'once', version: '1.3.0', bom: 'pkg:npm/once@1.3.0', purl: 'pkg:npm/once@1.3.0', scope: 'transitive', project: 'shared-root' }, + { name: 'once', version: '1.4.0', bom: 'pkg:npm/once@1.4.0', purl: 'pkg:npm/once@1.4.0', scope: 'direct', project: 'shared-extra' }, + { name: 'widget', version: '1.0.0', bom: 'pkg:npm/widget@1.0.0', purl: 'pkg:npm/widget@1.0.0', scope: 'direct', project: 'shared-root' }, ]); expect(new Set(rows.map((r) => r.bom)).size).toBe(rows.length); @@ -158,12 +159,12 @@ describe('sbom export: several versions of one package', () => { // ms exists only in the nested lockfile, so the root graph leaves it with no edges. expect(cdx.dependencies).toEqual([ { ref: 'vibgrate-root', dependsOn: ['pkg:npm/left-pad@1.3.0', 'pkg:npm/widget@1.0.0'] }, - { ref: 'pkg:npm/left-pad@1.3.0', dependsOn: [] }, - { ref: 'pkg:npm/widget@1.0.0', dependsOn: ['pkg:npm/left-pad@1.2.0'] }, - { ref: 'pkg:npm/once@1.4.0', dependsOn: [] }, { ref: 'pkg:npm/left-pad@1.2.0', dependsOn: ['pkg:npm/once@1.3.0'] }, + { ref: 'pkg:npm/left-pad@1.3.0', dependsOn: [] }, { ref: 'pkg:npm/ms@2.1.3', dependsOn: [] }, { ref: 'pkg:npm/once@1.3.0', dependsOn: [] }, + { ref: 'pkg:npm/once@1.4.0', dependsOn: [] }, + { ref: 'pkg:npm/widget@1.0.0', dependsOn: ['pkg:npm/left-pad@1.2.0'] }, ]); const spdx = toSpdx(scan, graph) as { @@ -177,20 +178,20 @@ describe('sbom export: several versions of one package', () => { relationships: Array<{ spdxElementId: string; relatedSpdxElementId: string; relationshipType: string }>; }; expect(spdx.packages.map((p) => [p.SPDXID, p.name, p.versionInfo, p.externalRefs[0]!.referenceLocator])).toEqual([ - ['SPDXRef-Package-1', 'left-pad', '1.3.0', 'pkg:npm/left-pad@1.3.0'], - ['SPDXRef-Package-2', 'widget', '1.0.0', 'pkg:npm/widget@1.0.0'], - ['SPDXRef-Package-3', 'once', '1.4.0', 'pkg:npm/once@1.4.0'], - ['SPDXRef-Package-4', 'left-pad', '1.2.0', 'pkg:npm/left-pad@1.2.0'], - ['SPDXRef-Package-5', 'ms', '2.1.3', 'pkg:npm/ms@2.1.3'], - ['SPDXRef-Package-6', 'once', '1.3.0', 'pkg:npm/once@1.3.0'], + ['SPDXRef-Package-1', 'left-pad', '1.2.0', 'pkg:npm/left-pad@1.2.0'], + ['SPDXRef-Package-2', 'left-pad', '1.3.0', 'pkg:npm/left-pad@1.3.0'], + ['SPDXRef-Package-3', 'ms', '2.1.3', 'pkg:npm/ms@2.1.3'], + ['SPDXRef-Package-4', 'once', '1.3.0', 'pkg:npm/once@1.3.0'], + ['SPDXRef-Package-5', 'once', '1.4.0', 'pkg:npm/once@1.4.0'], + ['SPDXRef-Package-6', 'widget', '1.0.0', 'pkg:npm/widget@1.0.0'], ]); - expect(spdx.packages[0]!.annotations[0]!.comment).toContain('scope=direct'); - expect(spdx.packages[3]!.annotations[0]!.comment).toContain('scope=transitive'); + expect(spdx.packages[1]!.annotations[0]!.comment).toContain('scope=direct'); + expect(spdx.packages[0]!.annotations[0]!.comment).toContain('scope=transitive'); expect(spdx.relationships).toEqual([ - { spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElementId: 'SPDXRef-Package-1', relationshipType: 'DEPENDS_ON' }, { spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElementId: 'SPDXRef-Package-2', relationshipType: 'DEPENDS_ON' }, - { spdxElementId: 'SPDXRef-Package-2', relatedSpdxElementId: 'SPDXRef-Package-4', relationshipType: 'DEPENDS_ON' }, - { spdxElementId: 'SPDXRef-Package-4', relatedSpdxElementId: 'SPDXRef-Package-6', relationshipType: 'DEPENDS_ON' }, + { spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElementId: 'SPDXRef-Package-6', relationshipType: 'DEPENDS_ON' }, + { spdxElementId: 'SPDXRef-Package-1', relatedSpdxElementId: 'SPDXRef-Package-4', relationshipType: 'DEPENDS_ON' }, + { spdxElementId: 'SPDXRef-Package-6', relatedSpdxElementId: 'SPDXRef-Package-1', relationshipType: 'DEPENDS_ON' }, ]); }); @@ -212,7 +213,7 @@ describe('sbom export: several versions of one package', () => { expect(second.serialNumber).not.toBe(first.serialNumber); }); - it('follows artifact project order for attribution and SPDX IDs, and keeps purls', () => { + it('keeps attribution on the first scanned project and assigns SPDX IDs from purl order', () => { writeRootLock('other-last'); const forward = scanned(); const reversed = artifact([...forward.projects].reverse()); @@ -229,10 +230,13 @@ describe('sbom export: several versions of one package', () => { expect(scopeOf(left(b).properties, 'vibgrate:project')).toBe('shared-extra'); expect(b.serialNumber).not.toBe(a.serialNumber); + expect(a.components.map((c) => c.purl)).toEqual(b.components.map((c) => c.purl)); + const spdxA = toSpdx(forward, graph) as { packages: Array<{ SPDXID: string; name: string }> }; const spdxB = toSpdx(reversed, graph) as { packages: Array<{ SPDXID: string; name: string }> }; - expect(spdxA.packages.find((p) => p.name === 'widget')!.SPDXID).toBe('SPDXRef-Package-2'); - expect(spdxB.packages.find((p) => p.name === 'widget')!.SPDXID).toBe('SPDXRef-Package-3'); + expect(spdxA.packages.map((p) => p.SPDXID)).toEqual(spdxB.packages.map((p) => p.SPDXID)); + expect(spdxA.packages.find((p) => p.name === 'widget')!.SPDXID).toBe('SPDXRef-Package-6'); + expect(spdxB.packages.find((p) => p.name === 'widget')!.SPDXID).toBe('SPDXRef-Package-6'); }); it('omits lockfile-only versions when the lockfile graph is absent (--no-transitive)', () => { @@ -241,11 +245,11 @@ describe('sbom export: several versions of one package', () => { components: Array<{ name: string; version: string; properties: Array<{ name: string; value: string }> }>; dependencies?: unknown; }; - expect(cdx.components.map((c) => `${c.name}@${c.version}`)).toEqual(['left-pad@1.3.0', 'widget@1.0.0', 'once@1.4.0']); + expect(cdx.components.map((c) => `${c.name}@${c.version}`)).toEqual(['left-pad@1.3.0', 'once@1.4.0', 'widget@1.0.0']); expect(cdx.components.map((c) => scopeOf(c.properties, 'vibgrate:scope'))).toEqual(['direct', 'direct', 'direct']); expect(cdx.dependencies).toBeUndefined(); const spdx = toSpdx(scan) as { packages: Array<{ name: string; versionInfo: string }>; relationships?: unknown }; - expect(spdx.packages.map((p) => `${p.name}@${p.versionInfo}`)).toEqual(['left-pad@1.3.0', 'widget@1.0.0', 'once@1.4.0']); + expect(spdx.packages.map((p) => `${p.name}@${p.versionInfo}`)).toEqual(['left-pad@1.3.0', 'once@1.4.0', 'widget@1.0.0']); expect(spdx.relationships).toBeUndefined(); }); @@ -259,6 +263,8 @@ describe('sbom export: several versions of one package', () => { expect(cdx.components.map((c) => c.name)).toEqual(['Flask', 'flask']); expect(cdx.components.map((c) => c.purl)).toEqual(['pkg:pypi/flask@3.0.0', 'pkg:pypi/flask@3.0.0']); expect(cdx.components.map((c) => c['bom-ref'])).toEqual(['pkg:pypi/flask@3.0.0', 'pkg:pypi/flask@3.0.0']); + const reversed = artifact([py('b'), py('a')]); + expect((toCycloneDx(reversed) as typeof cdx).components.map((c) => c.name)).toEqual(['Flask', 'flask']); const spdx = toSpdx(scan) as { packages: Array<{ SPDXID: string; externalRefs: Array<{ referenceLocator: string }> }>; }; diff --git a/test/sbom-lockfile-merge.test.ts b/test/sbom-lockfile-merge.test.ts index 7733d3c6..d726e8ac 100644 --- a/test/sbom-lockfile-merge.test.ts +++ b/test/sbom-lockfile-merge.test.ts @@ -113,9 +113,9 @@ describe('sbom export: multi-project lockfile merge', () => { }; expect(cdx.components.map((c) => `${c.purl}`)).toEqual([ 'pkg:npm/left-pad@1.3.0', - 'pkg:npm/widget@1.0.0', 'pkg:npm/once@1.3.0', 'pkg:npm/once@1.4.0', + 'pkg:npm/widget@1.0.0', 'pkg:npm/widget@2.0.0', ]); expect(new Set(cdx.components.map((c) => c['bom-ref'])).size).toBe(cdx.components.length); @@ -141,9 +141,9 @@ describe('sbom export: multi-project lockfile merge', () => { expect(cdx.dependencies).toEqual([ { ref: 'vibgrate-root', dependsOn: ['pkg:npm/left-pad@1.3.0', 'pkg:npm/widget@2.0.0'] }, { ref: 'pkg:npm/left-pad@1.3.0', dependsOn: ['pkg:npm/once@1.3.0'] }, - { ref: 'pkg:npm/widget@1.0.0', dependsOn: [] }, { ref: 'pkg:npm/once@1.3.0', dependsOn: [] }, { ref: 'pkg:npm/once@1.4.0', dependsOn: [] }, + { ref: 'pkg:npm/widget@1.0.0', dependsOn: [] }, { ref: 'pkg:npm/widget@2.0.0', dependsOn: [] }, ]); @@ -161,7 +161,7 @@ describe('sbom export: multi-project lockfile merge', () => { expect(spdx.relationships).toEqual([ { spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElementId: 'SPDXRef-Package-1', relationshipType: 'DEPENDS_ON' }, { spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElementId: 'SPDXRef-Package-5', relationshipType: 'DEPENDS_ON' }, - { spdxElementId: 'SPDXRef-Package-1', relatedSpdxElementId: 'SPDXRef-Package-3', relationshipType: 'DEPENDS_ON' }, + { spdxElementId: 'SPDXRef-Package-1', relatedSpdxElementId: 'SPDXRef-Package-2', relationshipType: 'DEPENDS_ON' }, ]); expect(collectMergeWarnings(scan, graph)).toEqual([LOSSY_EDGE_WARNING]); }); From 36bec404e8ccc0420a99e171ef4d77f725141dd9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 13:06:17 +0000 Subject: [PATCH 2/2] fix(sbom): order components by purl, then version, then name Sort SBOM components by Package URL when one is written, otherwise by package name, then by version. Rows that share a purl and a version are ordered by name. CycloneDX components, dependency entries, and SPDX packages, including SPDXRef-Package-N, follow that order. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 11 +- DOCS.md | 39 +++---- docs/sbom-dependency-scope.md | 2 +- src/engine/export.test.ts | 42 ++++--- src/engine/export.ts | 68 ++++++------ src/reporting/commands/sbom.test.ts | 1 - src/reporting/commands/sbom.ts | 73 ++++++------ test/sbom-component-order.test.ts | 160 --------------------------- test/sbom-duplicate-versions.test.ts | 11 +- test/sbom-lockfile-merge.test.ts | 36 ++++++ 10 files changed, 164 insertions(+), 279 deletions(-) delete mode 100644 test/sbom-component-order.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7074e900..b7ab0ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,16 @@ backward compatible. ### Fixed -- **SBOM component order no longer follows scan or filesystem order.** `vg sbom export` and CycloneDX/SPDX graph export sort components by Package URL when one is written, otherwise by name, then version, then ecosystem, before the JSON is serialized. CycloneDX `dependencies` (after the root entry) and SPDX `packages` use that order, and `SPDXRef-Package-N` follows it. The same scan still produces the same document on every run. Reordering projects still changes which project's metadata is kept, and therefore the document id, but it does not change component order. +- **SBOM component order no longer follows scan or filesystem order.** + `vg sbom export` and CycloneDX/SPDX graph export sort a component by its + Package URL when one is written, otherwise by package name, then by version. + Rows that share a Package URL and a version are ordered by name. CycloneDX + `components`, SPDX `packages`, and the package entries in CycloneDX + `dependencies` share that order, and `SPDXRef-Package-N` follows it. The + same artifact and the same lockfiles produce the same document on every run. + Reordering projects still changes which project's metadata is kept, and + therefore the document id, but it does not change component order or SPDX + IDs. (#286) - **`vg report --format` rejects values other than `md`, `text`, and `json`.** An unknown value, including `html`, exits `5` with a usage error that names diff --git a/DOCS.md b/DOCS.md index 28cdcfe3..58344085 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1622,7 +1622,7 @@ Use this to treat SBOMs as operational intelligence instead of static compliance `vg sbom export` writes one JSON document. `--format` accepts `cyclonedx` or `spdx`, compared without regard to case. The default is `cyclonedx`. Any other value prints `Invalid SBOM format. Use cyclonedx or spdx.` and the process exits 1. -Both formats are built from the same scan artifact and the same lockfiles under `--root`. The component list and its order are the same. Each specification then places those facts in its own fields. +Both formats are built from the same scan artifact and the same lockfiles under `--root`. The component list and its order are the same: Package URL when the component has one, otherwise the package name, then the version. Each specification then places those facts in its own fields. ```bash vg scan --offline @@ -1640,7 +1640,7 @@ vg sbom export --format spdx --out sbom.spdx.json | Created | `metadata.timestamp` is the artifact's `timestamp` | `creationInfo.created` is that same timestamp | | Scan root | `metadata.component` has `type` `application`, `bom-ref` `vibgrate-root`, and `name` set to the scan root. That component is not copied into `components` | There is no package for the scan root | | Package identity | `components[].purl`. When a purl is written, `bom-ref` is that purl | `packages[].externalRefs[]` with `referenceCategory` `PACKAGE-MANAGER`, `referenceType` `purl`, and `referenceLocator` set to the purl. `SPDXID` is `SPDXRef-Package-N` | -| Dependency graph | `dependencies` is an array of `{ ref, dependsOn }`. `ref` is a `bom-ref`. The first entry is `vibgrate-root`. Every component is an entry. `dependsOn` lists child `bom-ref` values, or is `[]` when none were recorded for that component | `relationships` is an array of `{ spdxElementId, relatedSpdxElementId, relationshipType }`. `relationshipType` is `DEPENDS_ON`. Edges from the document use `SPDXRef-DOCUMENT`. A row is written only for a recorded edge | +| Dependency graph | `dependencies` is an array of `{ ref, dependsOn }`. `ref` is a `bom-ref`. The first entry is `vibgrate-root`. Every component is an entry after that, in the component order above. `dependsOn` lists child `bom-ref` values, or is `[]` when none were recorded for that component | `relationships` is an array of `{ spdxElementId, relatedSpdxElementId, relationshipType }`. `relationshipType` is `DEPENDS_ON`. Edges from the document use `SPDXRef-DOCUMENT`. A row is written only for a recorded edge. `SPDXID` values follow the component order above | | Licenses | `licenses` is present when the declared license can be written, and absent when it cannot | `licenseDeclared` is set on every package. `licenseConcluded` is `NOASSERTION` on every package. `hasExtractedLicensingInfos` is present when a `LicenseRef-…` is used. Every package has `downloadLocation` `NOASSERTION` and `filesAnalyzed` `false` | `dataLicense` `CC0-1.0` is the SPDX license for the document data. Package licenses stay on `licenseDeclared`. @@ -1761,9 +1761,9 @@ Two other strings in the same file are easy to misread as package digests. `vcs. | `uv.lock` | `hash` | | `go.sum` | `h1:` | -**Several digests, one component.** A lockfile can list more than one digest for one package. The export still writes one component for that ecosystem, name, and version. It does not add a row per digest. `hashes` and `checksums` are omitted, so there is no digest array and no digest order to keep stable. Component order stays the order in [Several versions of one package](#several-versions-of-one-package): the Package URL when one is written, otherwise the package name, then the version, then the ecosystem. Digest text is not part of that sort, and it is not part of the document id. Exporting the same scan artifact again, after a lockfile edit that changes only those digest strings and leaves names, versions, and edges alone, writes the same JSON, including `serialNumber` and `documentNamespace`. +**Several digests, one component.** A lockfile can list more than one digest for one package. The export still writes one component for that ecosystem, name, and version. It does not add a row per digest. `hashes` and `checksums` are omitted, so there is no digest array and no digest order to keep stable. Component order stays the order in [Several versions of one package](#several-versions-of-one-package): Package URL when the component has one, otherwise the package name, then the version. Digest text is not part of that sort, and it is not part of the document id. Exporting the same scan artifact again, after a lockfile edit that changes only those digest strings and leaves names, versions, and edges alone, writes the same JSON, including `serialNumber` and `documentNamespace`. -`go.sum` lists a module twice: ` h1:…` and ` /go.mod h1:…`. The `/go.mod` line is not a second component. Both `h1:` values are dropped. A `uv.lock` package block can carry more than one `hash`. Those values are dropped, and the block stays one component, placed with the others by the order in [Several versions of one package](#several-versions-of-one-package). An npm `integrity` string and a pnpm `resolution.integrity` string are not read, including when the string names more than one algorithm. +`go.sum` lists a module twice: ` h1:…` and ` /go.mod h1:…`. The `/go.mod` line is not a second component. Both `h1:` values are dropped. A `uv.lock` package block can carry more than one `hash`. Those values are dropped, and the block stays one component, in the same order as the other rows. An npm `integrity` string and a pnpm `resolution.integrity` string are not read, including when the string names more than one algorithm. ```bash vg scan --offline --no-graph --format json --out scan.json @@ -1880,16 +1880,17 @@ absent, and so is the dependency graph. A consumer that filters to `vibgrate:scope=direct` sees the same gap: other installed versions of that package are still in the full document, marked `transitive`. -**Order.** Before the JSON is written, every component is sorted by its -Package URL when one is written, otherwise by package name, then by version, -then by ecosystem. The comparison is UTF-16 code unit order, so it does not -follow the locale, the order of `projects` on the scan artifact, the order of -a project's `dependencies` array, or the order a directory walk returned -those files. Direct rows and lockfile-only rows are one list. That list is -the order of CycloneDX `components`, the component entries of CycloneDX -`dependencies` (the first entry stays `vibgrate-root`), and SPDX `packages`. -`SPDXRef-Package-1` is the first package in that list. `dependsOn` entries -and the names inside one lockfile edge are sorted on their own. +**Order.** CycloneDX `components`, SPDX `packages`, and the package entries in +CycloneDX `dependencies` share one order. A component with a Package URL sorts +by that purl. A component without one sorts by package name. Version is the +next key. When two components share a purl and a version, package name orders +them (`Flask` before `flask`). `dependencies` starts with `vibgrate-root`, then +those components. The order is the same on every run of the same artifact and +the same lockfiles. It is independent of project order in the scan artifact, +directory walk order, and lockfile map order. `dependsOn` entries and the names +inside one lockfile edge are sorted on their own. SPDX `SPDXID` values are +`SPDXRef-Package-N` for that order, so they stay put when only discovery order +changes. The document id stays a content-derived UUID. **Same inputs, same document.** For one scan artifact and the lockfiles under `--root`, `vg sbom export` writes the same JSON on every run, including the @@ -1907,11 +1908,11 @@ are in [Package digests](#package-digests). - Which project's metadata is kept for a shared identity follows the scan artifact. Reordering projects changes `vibgrate:project` on that row and changes the document serial number and namespace, because the kept project - is part of the document id. Component order does not follow that project - order. SPDX `SPDXID` values and the `DEPENDS_ON` relationships that point - at them stay with the purl sort above. CycloneDX `bom-ref` stays on the - purl, so a scanner that stored the purl still matches. `vibgrate:projects` - stays the sorted set of contributing projects. + is part of the document id. Component order and SPDX `SPDXID` values stay + on the Package URL, or on the name and version when there is no purl, so + those identifiers do not move when only project order changes. CycloneDX + `bom-ref` stays on the purl, so a scanner that stored the purl still + matches. `vibgrate:projects` stays the sorted set of contributing projects. - npm `package-lock.json` v2/v3 collapses two install paths of the same `name@version` into one component. When those paths declare different dependencies, the edge list is the path that appears last in the lockfile diff --git a/docs/sbom-dependency-scope.md b/docs/sbom-dependency-scope.md index ede060dc..a912e3be 100644 --- a/docs/sbom-dependency-scope.md +++ b/docs/sbom-dependency-scope.md @@ -78,7 +78,7 @@ scope-fixture └── dev-optional-pkg@1.2.3 lockfile flags dev, optional, and devOptional ``` -Component order is the Package URL when one is written, otherwise the package name, then the version, then the ecosystem. Every row in this fixture has a purl, so the list is that purl order. It is not the scan's dependency array, and lockfile-only rows are not a second group after the direct rows. +Component order is the Package URL, then the version. Every row in this tree has a purl, so the names sort alphabetically and then by version. CycloneDX `dependencies` still starts with `vibgrate-root`. | Package | `vibgrate:scope` | CycloneDX component `scope` | Root `dependsOn` | SPDX annotation | SPDX relationship | | --- | --- | --- | --- | --- | --- | diff --git a/src/engine/export.test.ts b/src/engine/export.test.ts index b5167df1..f6fed50b 100644 --- a/src/engine/export.test.ts +++ b/src/engine/export.test.ts @@ -149,22 +149,36 @@ describe('cyclonedx export purl', () => { expect(bom.components.find((c) => c.name === 'requests')?.purl).toBeUndefined(); }); - it('sorts components by purl, then name, version, and ecosystem, independent of input order', () => { + it('two runs with reversed inputs emit components in purl, then name, then version order', () => { const base = ctx(makeGraph(false)); - const chalk = { name: 'chalk', ecosystem: 'npm' as const, declared: '^5.0.0', installed: '5.3.0' }; - const spaced = { name: 'foo bar', ecosystem: 'npm' as const, declared: '1.0.0', installed: '1.0.0' }; - const requests = { name: 'requests', ecosystem: 'pypi' as const, declared: '2.31.0', installed: '2.31.0' }; - const alpha = { runtime: 'ollama' as const, name: 'alpha', path: '/a' }; - const zeta = { runtime: 'gguf' as const, name: 'zeta', path: '/z' }; - const first = exportGraph('cyclonedx', { ...base, deps: [chalk, spaced, requests], models: [zeta, alpha] }); - const second = exportGraph('cyclonedx', { ...base, deps: [requests, spaced, chalk], models: [alpha, zeta] }); + const deps = [ + { name: 'zzz', ecosystem: 'npm' as const, declared: '1.0.0', installed: '1.0.0' }, + { name: 'foo bar', ecosystem: 'npm' as const, declared: '1.0.0', installed: '1.0.0' }, + { name: 'aaa', ecosystem: 'npm' as const, declared: '2.0.0', installed: '2.0.0' }, + { name: 'requests', ecosystem: 'pypi' as const, declared: '2.31.0', installed: '2.31.0' }, + ]; + const models = [ + { runtime: 'ollama' as const, name: 'llama3:latest', path: '/x' }, + { runtime: 'ollama' as const, name: 'aaa-model', path: '/y' }, + ]; + const first = exportGraph('cyclonedx', { ...base, deps, models }); + const second = exportGraph('cyclonedx', { + ...base, + deps: [...deps].reverse(), + models: [...models].reverse(), + }); expect(second).toBe(first); - const bom = JSON.parse(first) as { components: Array<{ name: string; type: string }> }; - expect(bom.components.map((c) => c.name)).toEqual(['foo bar', 'chalk', 'requests', 'alpha', 'zeta']); - const spdxFirst = exportGraph('spdx', { ...base, deps: [chalk, spaced, requests] }); - const spdxSecond = exportGraph('spdx', { ...base, deps: [requests, chalk, spaced] }); + const bom = JSON.parse(first) as { components: Array<{ name: string; purl?: string }> }; + expect(bom.components.map((c) => c.name)).toEqual(['aaa-model', 'foo bar', 'llama3:latest', 'aaa', 'zzz', 'requests']); + expect(bom.components.find((c) => c.name === 'aaa')?.purl).toBe('pkg:npm/aaa@2.0.0'); + expect(bom.components.find((c) => c.name === 'foo bar')?.purl).toBeUndefined(); + + const spdxFirst = exportGraph('spdx', { ...base, deps }); + const spdxSecond = exportGraph('spdx', { ...base, deps: [...deps].reverse() }); expect(spdxSecond).toBe(spdxFirst); - const doc = JSON.parse(spdxFirst) as { packages: Array<{ name: string }> }; - expect(doc.packages.map((p) => p.name)).toEqual(['foo bar', 'chalk', 'requests']); + const spdx = JSON.parse(spdxFirst) as { packages: Array<{ name: string; SPDXID: string }> }; + expect(spdx.packages.map((p) => p.name)).toEqual(['foo bar', 'aaa', 'zzz', 'requests']); + expect(spdx.packages.find((p) => p.name === 'aaa')?.SPDXID).toBe('SPDXRef-Package-aaa'); + expect(spdx.packages.find((p) => p.name === 'zzz')?.SPDXID).toBe('SPDXRef-Package-zzz'); }); }); diff --git a/src/engine/export.ts b/src/engine/export.ts index 1dcea2b1..ac9cd0c2 100644 --- a/src/engine/export.ts +++ b/src/engine/export.ts @@ -2,7 +2,7 @@ import { serializeGraph, slimGraphForExport } from './serialize.js'; import { renderReport } from './report.js'; import { renderHtml } from './html.js'; import type { DepRecord } from './drift.js'; -import { compareSbomComponentOrder, resolvePurl } from '../reporting/commands/sbom.js'; +import { compareSbomOrder, resolvePurl } from '../reporting/commands/sbom.js'; import type { LocalModel } from './models.js'; import type { VgGraph } from '../schema.js'; @@ -242,37 +242,21 @@ function sqlBool(v: boolean | null | undefined): string { return v == null ? 'NULL' : v ? '1' : '0'; } -/** Package URL this exporter actually writes for a dependency, or null when it omits one. */ -function emittedDepPurl(dep: DepRecord): string | null { - if (dep.ecosystem !== 'npm') return null; - return resolvePurl('npm', dep.name, dep.installed ?? '').purl; -} - -function sortExportDeps(deps: DepRecord[]): DepRecord[] { - return [...deps].sort((a, b) => - compareSbomComponentOrder( - { purl: emittedDepPurl(a), name: a.name, version: a.installed ?? a.declared, ecosystem: a.ecosystem }, - { purl: emittedDepPurl(b), name: b.name, version: b.installed ?? b.declared, ecosystem: b.ecosystem }, - ), - ); -} - -function sortExportModels(models: LocalModel[]): LocalModel[] { - return [...models].sort((a, b) => - compareSbomComponentOrder( - { purl: null, name: a.name, version: '', ecosystem: a.runtime }, - { purl: null, name: b.name, version: '', ecosystem: b.runtime }, - ), - ); +interface SbomLibraryComponent { + type: string; + name: string; + version?: string; + purl?: string; + properties?: Array<{ name: string; value: string | null }>; } function cyclonedx(ctx: ExportContext): string { // CycloneDX 1.6 JSON — dependencies as library components + local models as - // machine-learning-model components (AI-BOM). Library components are sorted - // before serialize (purl, else name, then version, then ecosystem). Models - // follow, sorted by name then runtime. No timestamps beyond the pinned generatedAt. - const components: unknown[] = []; - for (const d of sortExportDeps(ctx.deps ?? [])) { + // machine-learning-model components (AI-BOM). Component order is the Package + // URL when one is emitted, otherwise the name, then the version. No + // timestamps beyond the pinned generatedAt. + const components: SbomLibraryComponent[] = []; + for (const d of ctx.deps ?? []) { const version = d.installed ?? d.declared; if (d.ecosystem !== 'npm') { components.push({ type: 'library', name: d.name, version, purl: undefined }); @@ -295,9 +279,10 @@ function cyclonedx(ctx: ExportContext): string { }); } } - for (const m of sortExportModels(ctx.models ?? [])) { + for (const m of ctx.models ?? []) { components.push({ type: 'machine-learning-model', name: m.name, properties: [{ name: 'vg:runtime', value: m.runtime }] }); } + components.sort((a, b) => compareSbomOrder({ purl: a.purl, name: a.name, version: a.version }, { purl: b.purl, name: b.name, version: b.version })); const bom = { bomFormat: 'CycloneDX', specVersion: '1.6', @@ -307,13 +292,26 @@ function cyclonedx(ctx: ExportContext): string { return JSON.stringify(bom, null, 2) + '\n'; } +/** Purl this exporter actually writes for a dependency. Non-npm rows omit it. */ +function emittedExportPurl(d: DepRecord): string | null { + if (d.ecosystem !== 'npm') return null; + return resolvePurl('npm', d.name, d.installed ?? '').purl; +} + function spdx(ctx: ExportContext): string { - const packages = sortExportDeps(ctx.deps ?? []).map((d) => ({ - SPDXID: `SPDXRef-Package-${cypherLabel(d.name)}`, - name: d.name, - versionInfo: d.installed ?? d.declared, - downloadLocation: 'NOASSERTION', - })); + const packages = [...(ctx.deps ?? [])] + .sort((a, b) => + compareSbomOrder( + { purl: emittedExportPurl(a), name: a.name, version: a.installed ?? a.declared }, + { purl: emittedExportPurl(b), name: b.name, version: b.installed ?? b.declared }, + ), + ) + .map((d) => ({ + SPDXID: `SPDXRef-Package-${cypherLabel(d.name)}`, + name: d.name, + versionInfo: d.installed ?? d.declared, + downloadLocation: 'NOASSERTION', + })); const doc = { spdxVersion: 'SPDX-2.3', dataLicense: 'CC0-1.0', diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index e7988c6a..4fd6a599 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -330,7 +330,6 @@ describe('sbom helpers', () => { ]); expect(warnings[0]).toContain('empty path segment'); expect(warnings[1]).toContain('whitespace or a non-ASCII character'); - expect(warnings[2]).toContain('whitespace or a non-ASCII character'); const spdx = toSpdx(artifact) as { packages: Array<{ diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index addeb409..156a2c8c 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -303,6 +303,37 @@ function componentBomRef(ecosystem: Ecosystem, name: string, version: string): s return purlFor(ecosystem, name, version) ?? `vibgrate:${ecosystem}:${name}@${version}`; } +/** Code-unit order, so the result does not depend on the process locale. */ +function cmpText(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +/** + * Order for SBOM component and dependency rows. Primary key is the Package URL + * when one was emitted, otherwise the package name. Version is next. The name + * is the last key so two rows that share a purl and a version (PyPI `Flask` + * and `flask`) stay in a fixed order instead of discovery order. + */ +export function compareSbomOrder( + a: { purl?: string | null; name: string; version?: string | null }, + b: { purl?: string | null; name: string; version?: string | null }, +): number { + const aPrimary = a.purl ? a.purl : a.name; + const bPrimary = b.purl ? b.purl : b.name; + return cmpText(aPrimary, bPrimary) || cmpText(a.version ?? '', b.version ?? '') || cmpText(a.name, b.name); +} + +function compareFlattenedDependency(a: FlattenedDependency, b: FlattenedDependency): number { + return ( + compareSbomOrder( + { purl: purlFor(a.ecosystem, a.package, a.version), name: a.package, version: a.version }, + { purl: purlFor(b.ecosystem, b.package, b.version), name: b.package, version: b.version }, + ) || cmpText(a.ecosystem, b.ecosystem) + ); +} + function splitDependencyKey(key: string): { name: string; version: string } { const at = key.lastIndexOf('@'); return { name: key.slice(0, at), version: key.slice(at + 1) }; @@ -413,42 +444,6 @@ function sortedUnique(names: Iterable): string[] { return [...new Set(names)].sort((a, b) => a.localeCompare(b)); } -/** Identity fields used to order SBOM components. `purl` is null when the row has none. */ -export interface SbomComponentOrderKey { - purl: string | null; - name: string; - version: string; - ecosystem: string; -} - -/** - * Order for emitted components: Package URL when one is present, otherwise - * the package name, then version, then ecosystem. Comparison is UTF-16 code - * unit order so the result does not depend on locale, scan order, or the - * order a directory walk returned files. - */ -export function compareSbomComponentOrder(a: SbomComponentOrderKey, b: SbomComponentOrderKey): number { - return ( - cmpCodePoint(a.purl ?? a.name, b.purl ?? b.name) || - cmpCodePoint(a.name, b.name) || - cmpCodePoint(a.version, b.version) || - cmpCodePoint(a.ecosystem, b.ecosystem) - ); -} - -function cmpCodePoint(a: string, b: string): number { - if (a < b) return -1; - if (a > b) return 1; - return 0; -} - -function compareFlattenedDependency(a: FlattenedDependency, b: FlattenedDependency): number { - return compareSbomComponentOrder( - { purl: purlFor(a.ecosystem, a.package, a.version), name: a.package, version: a.version, ecosystem: a.ecosystem }, - { purl: purlFor(b.ecosystem, b.package, b.version), name: b.package, version: b.version, ecosystem: b.ecosystem }, - ); -} - function addProjects(row: FlattenedDependency, names: Iterable): void { row.projects = sortedUnique([...row.projects, ...names]); } @@ -562,10 +557,6 @@ interface MergedLockfileComponent extends LockfileComponent { * first and win over a lockfile row. The same identity from another project * stays one component; every contributing project is recorded. Differing * manifest metadata is dropped with a warning, not silently. - * - * The returned list is the emit order. It is sorted by Package URL when one - * can be built, otherwise by package name, then version, then ecosystem. - * Direct rows are not left in scan order ahead of the lockfile-only rows. */ export function flattenDependencies( artifact: ScanArtifact, @@ -648,6 +639,8 @@ export function flattenDependencies( index.set(key, row); lockfileOnly.push(row); } + // Sort before any serializer, warning list, or document-id seed reads this + // array. Discovery order (project walk, lockfile map) must not leak. return [...rows, ...lockfileOnly].sort(compareFlattenedDependency); } diff --git a/test/sbom-component-order.test.ts b/test/sbom-component-order.test.ts deleted file mode 100644 index 42e2ef3b..00000000 --- a/test/sbom-component-order.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Component order in `vg sbom export` is the Package URL when one is written, - * otherwise the name, then the version, then the ecosystem. Two exports of the - * same fixture match, including when the scan lists projects and dependencies - * in the opposite order (the order a directory walk can return). - */ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { collectLockfileGraph, toCycloneDx, toSpdx } from '../src/reporting/commands/sbom.js'; -import type { LockfileGraph } from '../src/engine/lockfile.js'; -import type { DependencyRow, ProjectScan, ScanArtifact } from '../src/reporting/types.js'; - -function dep(name: string, version: string): DependencyRow { - return { - package: name, - section: 'dependencies', - currentSpec: version, - resolvedVersion: version, - latestStable: version, - majorsBehind: 0, - drift: 'current', - }; -} - -function project(name: string, relPath: string, deps: DependencyRow[]): ProjectScan { - return { - type: 'node', - path: relPath, - name, - frameworks: [], - dependencies: deps, - dependencyAgeBuckets: { current: deps.length, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, - }; -} - -function artifact(projects: ProjectScan[]): ScanArtifact { - return { - schemaVersion: '1.0', - timestamp: '2026-02-19T00:00:00.000Z', - vibgrateVersion: '0.0.1', - rootPath: 'order-fixture', - drift: { - score: 10, - riskLevel: 'low', - components: { runtimeScore: 10, frameworkScore: 10, dependencyScore: 10, eolScore: 10 }, - }, - findings: [], - projects, - }; -} - -function writeLock(root: string, rel: string, body: unknown): void { - const dir = path.join(root, rel); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify(body)); -} - -/** Same packages; project and dependency arrays run in the other direction. */ -function reverseScan(scan: ScanArtifact): ScanArtifact { - return artifact( - [...scan.projects].reverse().map((projectScan) => ({ - ...projectScan, - dependencies: [...projectScan.dependencies].reverse(), - })), - ); -} - -/** Same edges and components, inserted in the opposite order. */ -function reverseGraph(graph: LockfileGraph): LockfileGraph { - const edges = graph.edges - ? new Map([...graph.edges.entries()].reverse().map(([key, children]) => [key, [...children].reverse()])) - : undefined; - return { - ...graph, - components: [...graph.components].reverse(), - edges, - rootDependsOn: [...graph.rootDependsOn].reverse(), - }; -} - -describe('sbom export: stable component order', () => { - let root: string; - - beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-sbom-order-')); - writeLock(root, 'apps/docs', { - name: 'docs', - lockfileVersion: 3, - requires: true, - packages: { - '': { name: 'docs', dependencies: { once: '1.4.0' } }, - 'node_modules/once': { version: '1.4.0' }, - }, - }); - writeLock(root, 'apps/web', { - name: 'web', - lockfileVersion: 3, - requires: true, - packages: { - '': { name: 'web', dependencies: { 'left-pad': '1.3.0', widget: '1.0.0' } }, - 'node_modules/left-pad': { version: '1.3.0' }, - 'node_modules/widget': { version: '1.0.0', dependencies: { 'ansi-styles': '6.2.1' } }, - 'node_modules/ansi-styles': { version: '6.2.1' }, - }, - }); - }); - - afterEach(() => fs.rmSync(root, { recursive: true, force: true })); - - it('exports the same component and dependency order twice, independent of scan order', () => { - const scan = artifact([ - project('web', 'apps/web', [dep('widget', '1.0.0'), dep('left-pad', '1.3.0'), dep('foo bar', '1.0.0')]), - project('docs', 'apps/docs', [dep('once', '1.4.0')]), - ]); - const graph = collectLockfileGraph(scan, root); - const again = collectLockfileGraph(scan, root); - expect(graph).toBeDefined(); - - const first = toCycloneDx(scan, graph); - const second = toCycloneDx(scan, again); - expect(JSON.stringify(second)).toBe(JSON.stringify(first)); - expect(JSON.stringify(toSpdx(scan, again))).toBe(JSON.stringify(toSpdx(scan, graph))); - - const reversed = toCycloneDx(reverseScan(scan), reverseGraph(graph!)); - const cdx = first as { - components: Array<{ name: string; purl?: string; 'bom-ref': string }>; - dependencies: Array<{ ref: string; dependsOn: string[] }>; - }; - const rev = reversed as typeof cdx; - const identity = (doc: typeof cdx): string[] => doc.components.map((c) => c.purl ?? c['bom-ref']); - - expect(identity(cdx)).toEqual([ - 'vibgrate:npm:foo bar@1.0.0', - 'pkg:npm/ansi-styles@6.2.1', - 'pkg:npm/left-pad@1.3.0', - 'pkg:npm/once@1.4.0', - 'pkg:npm/widget@1.0.0', - ]); - expect(identity(rev)).toEqual(identity(cdx)); - expect(cdx.dependencies.map((d) => d.ref)).toEqual(['vibgrate-root', ...identity(cdx)]); - expect(rev.dependencies).toEqual(cdx.dependencies); - - const spdx = toSpdx(scan, graph) as { - packages: Array<{ name: string; versionInfo: string; SPDXID: string }>; - relationships: Array<{ spdxElementId: string; relatedSpdxElementId: string; relationshipType: string }>; - }; - const spdxRev = toSpdx(reverseScan(scan), reverseGraph(graph!)) as typeof spdx; - expect(spdx.packages.map((p) => `${p.SPDXID} ${p.name}@${p.versionInfo}`)).toEqual([ - 'SPDXRef-Package-1 foo bar@1.0.0', - 'SPDXRef-Package-2 ansi-styles@6.2.1', - 'SPDXRef-Package-3 left-pad@1.3.0', - 'SPDXRef-Package-4 once@1.4.0', - 'SPDXRef-Package-5 widget@1.0.0', - ]); - expect(spdxRev.packages.map((p) => p.SPDXID)).toEqual(spdx.packages.map((p) => p.SPDXID)); - expect(spdxRev.relationships).toEqual(spdx.relationships); - }); -}); diff --git a/test/sbom-duplicate-versions.test.ts b/test/sbom-duplicate-versions.test.ts index 3cf03f68..f3c5eac2 100644 --- a/test/sbom-duplicate-versions.test.ts +++ b/test/sbom-duplicate-versions.test.ts @@ -1,8 +1,7 @@ /** * Pins the documented `vg sbom export` contract for several resolved versions * of one package: identity, dedup, scope, order, and the known limitations - * (kept-project attribution follows the scan artifact, last lockfile path - * wins the edges, PyPI purl folding). Component order follows the purl sort. + * (artifact order, last lockfile path wins the edges, PyPI purl folding). */ import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -213,7 +212,7 @@ describe('sbom export: several versions of one package', () => { expect(second.serialNumber).not.toBe(first.serialNumber); }); - it('keeps attribution on the first scanned project and assigns SPDX IDs from purl order', () => { + it('keeps attribution from artifact project order and assigns SPDX IDs from the stable component order', () => { writeRootLock('other-last'); const forward = scanned(); const reversed = artifact([...forward.projects].reverse()); @@ -230,13 +229,11 @@ describe('sbom export: several versions of one package', () => { expect(scopeOf(left(b).properties, 'vibgrate:project')).toBe('shared-extra'); expect(b.serialNumber).not.toBe(a.serialNumber); - expect(a.components.map((c) => c.purl)).toEqual(b.components.map((c) => c.purl)); - const spdxA = toSpdx(forward, graph) as { packages: Array<{ SPDXID: string; name: string }> }; const spdxB = toSpdx(reversed, graph) as { packages: Array<{ SPDXID: string; name: string }> }; expect(spdxA.packages.map((p) => p.SPDXID)).toEqual(spdxB.packages.map((p) => p.SPDXID)); expect(spdxA.packages.find((p) => p.name === 'widget')!.SPDXID).toBe('SPDXRef-Package-6'); - expect(spdxB.packages.find((p) => p.name === 'widget')!.SPDXID).toBe('SPDXRef-Package-6'); + expect(spdxB.packages.find((p) => p.name === 'widget')!.SPDXID).toBe(spdxA.packages.find((p) => p.name === 'widget')!.SPDXID); }); it('omits lockfile-only versions when the lockfile graph is absent (--no-transitive)', () => { @@ -263,8 +260,6 @@ describe('sbom export: several versions of one package', () => { expect(cdx.components.map((c) => c.name)).toEqual(['Flask', 'flask']); expect(cdx.components.map((c) => c.purl)).toEqual(['pkg:pypi/flask@3.0.0', 'pkg:pypi/flask@3.0.0']); expect(cdx.components.map((c) => c['bom-ref'])).toEqual(['pkg:pypi/flask@3.0.0', 'pkg:pypi/flask@3.0.0']); - const reversed = artifact([py('b'), py('a')]); - expect((toCycloneDx(reversed) as typeof cdx).components.map((c) => c.name)).toEqual(['Flask', 'flask']); const spdx = toSpdx(scan) as { packages: Array<{ SPDXID: string; externalRefs: Array<{ referenceLocator: string }> }>; }; diff --git a/test/sbom-lockfile-merge.test.ts b/test/sbom-lockfile-merge.test.ts index d726e8ac..0099a52b 100644 --- a/test/sbom-lockfile-merge.test.ts +++ b/test/sbom-lockfile-merge.test.ts @@ -331,4 +331,40 @@ describe('sbom export: multi-project lockfile merge', () => { expect(stderr.join('\n')).toContain(`warning [VG_WARN_SBOM_LOSSY_EDGES]: ${LOSSY_EDGE_WARNING}`); expect(stderr.join('\n')).not.toContain('http'); }); + + it('two runs on a fixture emit the same component and dependency order', () => { + const base = scanned(); + const graph = collectLockfileGraph(base, root); + expect(graph?.edges).toBeDefined(); + const place = (where: 'start' | 'end') => { + const scan = scanned(); + const row = dep('foo bar', '1.0.0'); + const deps = scan.projects[0]!.dependencies; + if (where === 'start') deps.unshift(row); + else deps.push(row); + return scan; + }; + const reversed = { + components: [...graph!.components].reverse(), + edges: new Map([...graph!.edges!.entries()].reverse()), + rootDependsOn: [...graph!.rootDependsOn].reverse(), + ecosystem: graph!.ecosystem, + }; + const first = toCycloneDx(place('start'), graph); + const second = toCycloneDx(place('end'), reversed); + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + const doc = first as { + serialNumber: string; + components: Array<{ name: string; purl?: string; 'bom-ref': string }>; + dependencies: Array<{ ref: string }>; + }; + const rerun = toCycloneDx(place('start'), collectLockfileGraph(base, root)) as { serialNumber: string }; + expect(rerun.serialNumber).toBe(doc.serialNumber); + expect(doc.serialNumber).toMatch(/^urn:uuid:/); + expect(doc.components.map((c) => c.name)).toEqual(['foo bar', 'left-pad', 'once', 'once', 'widget', 'widget']); + expect(doc.components[0]!.purl).toBeUndefined(); + expect(doc.dependencies[0]!.ref).toBe('vibgrate-root'); + expect(doc.dependencies.slice(1).map((d) => d.ref)).toEqual(doc.components.map((c) => c['bom-ref'])); + expect(JSON.stringify(toSpdx(place('start'), graph))).toBe(JSON.stringify(toSpdx(place('end'), reversed))); + }); });