Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,30 @@ describe('diff', () => {
expect(report.removed[0].name).toBe('moment');
});

it('detects version upgrades', () => {
it('detects version upgrades when the purl carries the version', () => {
// Real-world generators embed the version in the purl. The same package at a
// new version must be reported as an upgrade, not a removal plus an addition.
const a = makesbom([{ name: 'lodash', version: '4.17.20', purl: 'pkg:npm/lodash@4.17.20' }]);
const b = makesbom([{ name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21' }]);
const report = diff(a, b);
// Different purl = treated as add/remove (purl includes version)
// With our current purl-based key: 4.17.20 -> removed, 4.17.21 -> added
// This is correct behavior — different purls are different packages
expect(report.added.length + report.removed.length + report.upgraded.length).toBeGreaterThan(0);
expect(report.added).toHaveLength(0);
expect(report.removed).toHaveLength(0);
expect(report.upgraded).toHaveLength(1);
expect(report.upgraded[0].from).toBe('4.17.20');
expect(report.upgraded[0].to).toBe('4.17.21');
expect(report.upgraded[0].isMajorBump).toBe(false);
});

it('matches scoped packages across versions ignoring purl qualifiers', () => {
const a = makesbom([
{ name: '@angular/core', version: '15.2.0', purl: 'pkg:npm/%40angular/core@15.2.0?type=module' },
]);
const b = makesbom([
{ name: '@angular/core', version: '16.0.0', purl: 'pkg:npm/%40angular/core@16.0.0?type=module' },
]);
const report = diff(a, b);
expect(report.upgraded).toHaveLength(1);
expect(report.upgraded[0].isMajorBump).toBe(true);
});

it('detects version upgrades when matched by name (no purl)', () => {
Expand Down
32 changes: 29 additions & 3 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,39 @@ export function diff(a: SBOM, b: SBOM): ChangeReport {
function buildComponentMap(components: Component[]): Map<string, Component> {
const map = new Map<string, Component>();
for (const comp of components) {
// Prefer purl as key, fall back to name
const key = comp.purl ?? comp.name;
map.set(key, comp);
map.set(componentKey(comp), comp);
}
return map;
}

/**
* Build a version-independent identity key for a component so the same
* package can be matched across two SBOMs even when its version changed.
*
* Real-world CycloneDX/SPDX generators almost always embed the version in the
* purl (e.g. "pkg:npm/lodash@4.17.21"), so keying on the raw purl would make an
* upgrade look like a removal plus an addition and hide it from the "upgraded"
* report entirely. We therefore strip the version — and any qualifiers/subpath,
* which can also vary between builds — leaving the stable "pkg:type/namespace/name"
* identity. Components without a purl fall back to their name.
*/
function componentKey(comp: Component): string {
return comp.purl ? purlIdentity(comp.purl) : comp.name;
}

/**
* Strip the version (`@...`), qualifiers (`?...`), and subpath (`#...`) from a
* purl, returning the `pkg:type/namespace/name` portion. In a purl any literal
* `@` in the name or namespace is percent-encoded (e.g. the npm scope `@angular`
* becomes `%40angular`), so the first unescaped `@` reliably marks the version.
*/
function purlIdentity(purl: string): string {
const withoutSubpath = purl.split('#', 1)[0];
const withoutQualifiers = withoutSubpath.split('?', 1)[0];
const atIndex = withoutQualifiers.indexOf('@');
return atIndex === -1 ? withoutQualifiers : withoutQualifiers.slice(0, atIndex);
}

/**
* Returns true if the major version changed (semver-style).
* Handles versions like "1.2.3", "2.0.0-beta", etc.
Expand Down
Loading