From 414cc37cbe8d7d4a5472375a3fc43edfe6dd5325 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 03:04:13 +0000 Subject: [PATCH] fix(diff): match components by version-independent key so upgrades are detected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildComponentMap keyed on the raw purl, which embeds the version (e.g. "pkg:npm/lodash@4.17.21"). Real-world CycloneDX/SPDX SBOMs almost always emit version-qualified purls, so an upgrade produced two distinct keys and was reported as a spurious remove + add — the `upgraded` array only ever populated for the rare purl-less, name-matched case. This silently broke the tool's headline feature (upgraded-dependency detection). Introduce componentKey()/purlIdentity(): strip the "@version" segment from the purl while preserving any ?qualifiers/#subpath, and fall back to an ecosystem-qualified name when no purl is present. Scoped npm purls (leading @ encoded as %40) resolve correctly since the version "@" is the first literal "@". Update the misleading test that asserted the broken behavior and add coverage for scoped and qualifier-bearing purls. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013JKKFxnxFWMNXYnbW3wM4q --- CHANGELOG.md | 10 ++++++++++ src/__tests__/diff.test.ts | 30 +++++++++++++++++++++++++----- src/diff.ts | 35 ++++++++++++++++++++++++++++++++--- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f3c2a..c4cc274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed +- `diff()` now matches components by a version-independent identity key, so a + version change on a component that carries a version-qualified purl (e.g. + `pkg:npm/lodash@4.17.20` → `pkg:npm/lodash@4.17.21`) is reported as an + **upgrade** instead of a spurious remove + add. Previously the raw purl — + which embeds the version — was used as the match key, so upgrade detection + (the tool's headline feature) never triggered for real-world SBOMs, which + almost always emit version-qualified purls. Scoped npm purls (`%40`-encoded) + and purls with `?qualifiers`/`#subpath` are handled correctly. + ### Added - Real devDependencies: `typescript`, `vitest`, `@vitest/coverage-v8`, `typescript-eslint`, `@types/node` - `src/types.ts` — Full domain model: `SBOM`, `Component`, `CVEEntry`, `ChangeReport`, `VersionChange`, `SBOMFormat`, `ReportFormat` diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..c9acad3 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -42,14 +42,34 @@ describe('diff', () => { expect(report.removed[0].name).toBe('moment'); }); - it('detects version upgrades', () => { + it('detects version upgrades when matched by version-qualified purl', () => { + // Real SBOMs emit version-qualified purls. The identity key must ignore the + // "@version" segment so this is reported as an upgrade, not remove + add. 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 npm purls across versions (leading @ is percent-encoded)', () => { + 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('matches purls with qualifiers across versions', () => { + const a = makesbom([{ name: 'openssl', version: '1.1.1', purl: 'pkg:deb/debian/openssl@1.1.1?arch=amd64' }]); + const b = makesbom([{ name: 'openssl', version: '3.0.2', purl: 'pkg:deb/debian/openssl@3.0.2?arch=amd64' }]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(1); + expect(report.added).toHaveLength(0); + expect(report.removed).toHaveLength(0); }); it('detects version upgrades when matched by name (no purl)', () => { diff --git a/src/diff.ts b/src/diff.ts index 6540b50..d248c51 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -63,13 +63,42 @@ 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; } +/** + * Compute a version-independent identity key for a component. + * + * A component's identity must be stable across version changes so that an + * upgrade (lodash 4.17.20 -> 4.17.21) matches the same key in both SBOMs + * and is reported as an upgrade rather than a remove + add. Because purls + * embed the version (e.g. "pkg:npm/lodash@4.17.21"), keying on the raw purl + * would give every version a distinct key and break upgrade detection — + * the tool's headline feature. So we strip the "@version" segment while + * preserving any qualifiers/subpath that follow it. + */ +function componentKey(comp: Component): string { + if (comp.purl) return purlIdentity(comp.purl); + // No purl: fall back to ecosystem-qualified name (or bare name). + return comp.ecosystem ? `${comp.ecosystem}:${comp.name}` : comp.name; +} + +/** + * Strip the version from a purl, keeping the type/namespace/name and any + * trailing ?qualifiers or #subpath. purl format: + * pkg:type/namespace/name@version?qualifiers#subpath + * The version separator is the first unescaped "@" (scoped npm names encode + * their leading "@" as "%40", so indexOf finds the version's "@"). + */ +function purlIdentity(purl: string): string { + const atIdx = purl.indexOf('@'); + if (atIdx === -1) return purl; + const tail = purl.slice(atIdx).match(/[?#].*$/); + return purl.slice(0, atIdx) + (tail ? tail[0] : ''); +} + /** * Returns true if the major version changed (semver-style). * Handles versions like "1.2.3", "2.0.0-beta", etc.