diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..8bb1510 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -42,14 +42,31 @@ describe('diff', () => { expect(report.removed[0].name).toBe('moment'); }); - it('detects version upgrades', () => { + it('detects version upgrades matched by purl (version stripped from key)', () => { 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); + // A purl embeds the version, but the same package upgraded is one dependency, + // not an unrelated add + remove. The diff strips the version from the purl so + // this is reported as a single upgrade. + 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('detects a major-version upgrade of a scoped (namespaced) purl', () => { + // The scope "@angular" is percent-encoded as %40 in a purl, so only the + // version delimiter is a literal "@" — stripping it must not touch the scope. + const a = makesbom([{ name: '@angular/core', version: '15.2.0', purl: 'pkg:npm/%40angular/core@15.2.0' }]); + const b = makesbom([{ name: '@angular/core', version: '16.0.0', purl: 'pkg:npm/%40angular/core@16.0.0' }]); + const report = diff(a, b); + expect(report.added).toHaveLength(0); + expect(report.removed).toHaveLength(0); + 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..cb77d6d 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -63,13 +63,37 @@ 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; } +/** + * Derive a stable, version-independent identity for a component. + * + * A purl embeds the version (e.g. "pkg:npm/lodash@4.17.21"), so keying the diff + * on the raw purl would make every upgrade look like an unrelated remove + add — + * defeating the tool's headline "upgraded dependencies" detection. Strip the + * version so the same package at two versions maps to one identity. Falls back to + * the name (also version-stripped by construction) when no purl is present. + */ +function componentKey(comp: Component): string { + return comp.purl ? stripPurlVersion(comp.purl) : comp.name; +} + +/** + * Remove the version segment from a package URL. + * + * In a purl the version follows the first *literal* "@"; any "@" inside the + * namespace (e.g. an npm scope "@angular") is percent-encoded as "%40", so the + * first literal "@" always delimits the version. Qualifiers ("?...") and the + * subpath ("#...") are preserved so components that differ only by those are + * still distinguished. + */ +function stripPurlVersion(purl: string): string { + return purl.replace(/@[^?#]*/, ''); +} + /** * Returns true if the major version changed (semver-style). * Handles versions like "1.2.3", "2.0.0-beta", etc.