diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..daa7839 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -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)', () => { diff --git a/src/diff.ts b/src/diff.ts index 6540b50..e0f59e2 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -63,13 +63,39 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { function buildComponentMap(components: Component[]): Map { const map = new Map(); 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.