diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 82a72ee..1c95e0a 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -307,4 +307,40 @@ describe('diff ordering', () => { expect(report.upgraded).toHaveLength(1); expect(report.hashChanges).toHaveLength(0); }); + + it('reports components that share a key instead of silently dropping them (issue #50)', () => { + // Two purl-less components with the same name coexist in the same SBOM + // (common in OS-package / container SBOMs). The old last-write-wins map + // silently discarded the first, hiding a real removal. + const a = makesbom([ + { name: 'kernel', version: '5.15.0' }, + { name: 'kernel', version: '5.19.0' }, + ]); + const b = makesbom([ + { name: 'kernel', version: '5.15.0' }, + ]); + const report = diff(a, b); + // Both entries survived: one matches (unchanged), the other is a removal. + expect(report.removed).toHaveLength(1); + expect(report.removed[0].version).toBe('5.19.0'); + }); + + it('matches same-key components across SBOMs by occurrence order', () => { + const a = makesbom([ + { name: 'dup', version: '1.0.0' }, + { name: 'dup', version: '2.0.0' }, + ]); + const b = makesbom([ + { name: 'dup', version: '1.0.0' }, + { name: 'dup', version: '3.0.0' }, + ]); + const report = diff(a, b); + // First occurrence pairs 1.0.0<->1.0.0 (unchanged); second pairs + // 2.0.0<->3.0.0 (upgrade). No add/remove false positives. + expect(report.upgraded).toHaveLength(1); + expect(report.upgraded[0].from).toBe('2.0.0'); + expect(report.upgraded[0].to).toBe('3.0.0'); + expect(report.added).toHaveLength(0); + expect(report.removed).toHaveLength(0); + }); }); diff --git a/src/diff.ts b/src/diff.ts index 54555a1..1d75411 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -153,10 +153,28 @@ function toIdentity(sbom: SBOM): SBOMIdentity { }; } +/** + * Build a component lookup map, disambiguating components that share a key. + * + * buildComponentMap previously used last-write-wins: when a single SBOM + * contained two components mapping to the same `purl ?? name` key, every entry + * but the last was silently discarded *before* the diff ran, so added/removed + * packages could vanish from the report entirely (issue #50). + * + * Instead, every component gets a unique key: the first occurrence keeps the + * bare key, subsequent collisions get a `#2`, `#3`, … suffix. All entries + * survive into the map and are compared. Ordering is deterministic (stable + * input order) so the same SBOM always yields the same keys. + */ function buildComponentMap(components: Component[]): Map { const map = new Map(); + const seen = new Map(); for (const comp of components) { - map.set(componentKey(comp), comp); + const base = componentKey(comp); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + const key = count === 0 ? base : `${base}#${count + 1}`; + map.set(key, comp); } return map; }