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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Fixed
- `diff()` now matches components by a version-agnostic purl key, so a package whose version changes is reported as an **upgrade** instead of a separate add + remove. Real-world SBOMs embed the version in the purl (e.g. `pkg:npm/lodash@4.17.21`), which previously defeated upgrade detection — the package's headline feature. Scoped npm packages (`pkg:npm/%40babel/core@…`) are matched correctly.
- CLI no longer crashes when run without `--format`. `sbom-diff old.json new.json` (the primary example in the README) previously parsed the first filename as the format and threw `Unsupported format`. Invalid `--format` values now produce a clear error instead of a stack trace.

### 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`
Expand Down
23 changes: 18 additions & 5 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,27 @@ describe('diff', () => {
expect(report.removed[0].name).toBe('moment');
});

it('detects version upgrades', () => {
it('detects version upgrades when matched by versioned purl', () => {
// purls embed the version, but the same package should still be matched
// across versions so the change is reported as an upgrade, not add/remove.
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 packages across versions', () => {
// Scoped packages encode the namespace "@" as "%40"; only the version "@"
// is literal, so the package must still match across versions.
const a = makesbom([{ name: '@babel/core', version: '7.0.0', purl: 'pkg:npm/%40babel/core@7.0.0' }]);
const b = makesbom([{ name: '@babel/core', version: '7.1.0', purl: 'pkg:npm/%40babel/core@7.1.0' }]);
const report = diff(a, b);
expect(report.upgraded).toHaveLength(1);
expect(report.upgraded[0].to).toBe('7.1.0');
});

it('detects version upgrades when matched by name (no purl)', () => {
Expand Down
9 changes: 8 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ async function main(): Promise<void> {
}

const [oldPath, newPath] = positional;
const flagIndex = args.indexOf('--format');
const formatArg = args.find(a => a.startsWith('--format='))?.split('=')[1]
?? args[args.indexOf('--format') + 1];
?? (flagIndex !== -1 ? args[flagIndex + 1] : undefined);
const format: ReportFormat = (formatArg as ReportFormat) ?? 'text';

const validFormats: ReportFormat[] = ['text', 'json', 'markdown'];
if (!validFormats.includes(format)) {
console.error(`Unknown format: ${format}. Use one of: ${validFormats.join(', ')}`);
process.exit(1);
}

const [oldRaw, newRaw] = await Promise.all([
readFile(oldPath, 'utf-8'),
readFile(newPath, 'utf-8'),
Expand Down
27 changes: 23 additions & 4 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange } from './t
* Compare two parsed SBOMs and produce a ChangeReport.
*
* Matching strategy:
* 1. By purl (most precise)
* 1. By version-agnostic purl (most precise)
* 2. By name (fallback)
*
* The purl is stripped of its version so the same package matches across
* SBOMs even when its version changes — this is what enables upgrade
* detection (see {@link componentKey}).
*/
export function diff(a: SBOM, b: SBOM): ChangeReport {
const aMap = buildComponentMap(a.components);
Expand Down Expand Up @@ -63,13 +67,28 @@ 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;
}

/**
* Build a version-agnostic identity key for a component so the same package
* matches across SBOMs even when its version changes (enabling upgrade
* detection). purls embed the version after "@"
* (e.g. "pkg:npm/lodash@4.17.21"), so we strip everything from the first
* literal "@" onward. Scoped npm namespaces encode their "@" as "%40", so the
* first literal "@" is always the version delimiter. Falls back to name when
* no purl is present.
*/
function componentKey(comp: Component): string {
if (comp.purl) {
const at = comp.purl.indexOf('@');
return at === -1 ? comp.purl : comp.purl.slice(0, at);
}
return comp.name;
}

/**
* Returns true if the major version changed (semver-style).
* Handles versions like "1.2.3", "2.0.0-beta", etc.
Expand Down
Loading