From 47d32fb9db731ca77467695231f68ea8d5b3dd3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 03:08:01 +0000 Subject: [PATCH] fix(diff): detect upgrades when purls embed the version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component identity was keyed on the raw purl, but real CycloneDX/SPDX SBOMs embed the version in the purl (e.g. "pkg:npm/lodash@4.17.21"). A version bump therefore produced two distinct keys, so it was reported as one added + one removed component and the "upgraded" bucket (and its isMajorBump logic) stayed empty for virtually every real SBOM. Key components by a version-independent identity: the purl with the "@version" segment stripped (qualifiers/subpath removed first, and only a literal "@" — not a "%40"-encoded npm scope — treated as the delimiter), falling back to name when no purl is present. Add regression tests for plain, scoped, and distinct-package cases; update the test that had codified the old add/remove behavior as expected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G3ef36saEoGYf6ZhTiDDYc --- src/__tests__/diff.test.ts | 31 +++++++++++++++++++++++++----- src/diff.ts | 39 ++++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..e2693b5 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -42,14 +42,35 @@ describe('diff', () => { expect(report.removed[0].name).toBe('moment'); }); - it('detects version upgrades', () => { + it('detects version upgrades when purls embed the version', () => { + // Real CycloneDX/SPDX SBOMs put the version in the purl, so the identity + // key must ignore it — otherwise a bump reads as add + remove, not upgrade. 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'); + }); + + it('matches scoped-package purls across an upgrade', () => { + // npm scopes encode "@" as "%40"; only the literal "@" delimits the version. + const a = makesbom([{ name: '@angular/core', version: '15.0.0', purl: 'pkg:npm/%40angular/core@15.0.0' }]); + const b = makesbom([{ name: '@angular/core', version: '16.1.0', purl: 'pkg:npm/%40angular/core@16.1.0' }]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(1); + expect(report.upgraded[0].isMajorBump).toBe(true); + }); + + it('does not confuse different packages that share a version bump', () => { + const a = makesbom([{ name: 'left-pad', version: '1.0.0', purl: 'pkg:npm/left-pad@1.0.0' }]); + const b = makesbom([{ name: 'right-pad', version: '1.0.0', purl: 'pkg:npm/right-pad@1.0.0' }]); + const report = diff(a, b); + expect(report.added).toHaveLength(1); + expect(report.removed).toHaveLength(1); + expect(report.upgraded).toHaveLength(0); }); it('detects version upgrades when matched by name (no purl)', () => { diff --git a/src/diff.ts b/src/diff.ts index 6540b50..6e51503 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -3,9 +3,10 @@ import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange } from './t /** * Compare two parsed SBOMs and produce a ChangeReport. * - * Matching strategy: - * 1. By purl (most precise) - * 2. By name (fallback) + * Matching strategy — a component's *identity* is version-independent so that a + * version bump reads as an upgrade rather than an add + remove: + * 1. By purl with the version stripped (e.g. "pkg:npm/lodash"), most precise + * 2. By name (fallback when no purl is present) */ export function diff(a: SBOM, b: SBOM): ChangeReport { const aMap = buildComponentMap(a.components); @@ -63,13 +64,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; } +/** + * Version-independent identity for a component. + * + * A purl normally embeds the version (e.g. "pkg:npm/lodash@4.17.21"), so keying + * on the raw purl would make every version change look like a separate package + * being added and removed — the "upgraded" bucket would stay empty for any real + * SBOM. Strip the version so the same package across two SBOMs shares one key. + */ +function componentKey(comp: Component): string { + if (comp.purl) return stripPurlVersion(comp.purl); + return `name:${comp.name}`; +} + +/** + * Remove the "@version" segment from a purl, preserving the type/namespace/name. + * + * purl syntax: "pkg:type/namespace/name@version?qualifiers#subpath". The version + * is delimited by a literal "@"; an "@" inside a name (e.g. an npm scope) is + * percent-encoded as "%40", so a literal "@" unambiguously begins the version. + * Qualifiers ("?") and subpath ("#") are dropped first so an "@" there can't be + * mistaken for the version delimiter. + */ +function stripPurlVersion(purl: string): string { + const base = purl.split('#')[0].split('?')[0]; + const at = base.indexOf('@', base.indexOf('/')); + return at === -1 ? base : base.slice(0, at); +} + /** * Returns true if the major version changed (semver-style). * Handles versions like "1.2.3", "2.0.0-beta", etc.