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
27 changes: 22 additions & 5 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
30 changes: 27 additions & 3 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,37 @@ 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;
}

/**
* 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.
Expand Down
Loading