diff --git a/package-lock.json b/package-lock.json index 0934876..72185ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,9 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.59.3", "vitest": "^4.1.6" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@babel/helper-string-parser": { @@ -492,9 +495,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -512,9 +512,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -532,9 +529,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -552,9 +546,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -572,9 +563,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -592,9 +580,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2187,9 +2172,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2211,9 +2193,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2235,9 +2214,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2259,9 +2235,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..420dd53 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -42,14 +42,25 @@ describe('diff', () => { expect(report.removed[0].name).toBe('moment'); }); - it('detects version upgrades', () => { + it('detects version upgrades matched by purl (purl includes version)', () => { 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); + // The purl embeds the version, but it is the same package upgraded — it + // must be reported as an upgrade, not as a remove + add. + 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 packages by purl ignoring the version', () => { + 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: '8.0.0', purl: 'pkg:npm/%40babel/core@8.0.0' }]); + const report = diff(a, b); + 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/cli.ts b/src/cli.ts index 986eb1a..49ebc48 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,9 +23,15 @@ async function main(): Promise { const [oldPath, newPath] = positional; const formatArg = args.find(a => a.startsWith('--format='))?.split('=')[1] - ?? args[args.indexOf('--format') + 1]; + ?? (args.includes('--format') ? args[args.indexOf('--format') + 1] : undefined); const format: ReportFormat = (formatArg as ReportFormat) ?? 'text'; + const validFormats: ReportFormat[] = ['text', 'json', 'markdown']; + if (!validFormats.includes(format)) { + console.error(`Invalid --format "${format}". Use one of: ${validFormats.join(', ')}`); + process.exit(1); + } + const [oldRaw, newRaw] = await Promise.all([ readFile(oldPath, 'utf-8'), readFile(newPath, 'utf-8'), diff --git a/src/diff.ts b/src/diff.ts index 6540b50..b9e3211 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -63,13 +63,35 @@ 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; } +/** + * Build a version-independent identity key for a component, so the same + * package at two different versions matches and is reported as an upgrade + * rather than a remove + add. + * + * purls embed the version after the version delimiter "@" (e.g. + * "pkg:npm/lodash@4.17.21"), so we strip it. We fall back to the bare name + * when no purl is present. + */ +function componentKey(comp: Component): string { + return comp.purl ? stripPurlVersion(comp.purl) : comp.name; +} + +/** + * Remove the version (and any trailing qualifiers/subpath) from a purl, + * leaving the version-independent coordinates. The "@" used to separate the + * version is the only unencoded "@" in a valid purl — namespace "@" (e.g. + * npm scopes) is percent-encoded as "%40". + */ +function stripPurlVersion(purl: string): string { + const at = purl.indexOf('@'); + return at === -1 ? purl : purl.slice(0, at); +} + /** * Returns true if the major version changed (semver-style). * Handles versions like "1.2.3", "2.0.0-beta", etc.