From 8a3302ad1a01deae9f84bf84656233b80f1125ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 03:10:07 +0000 Subject: [PATCH] fix(diff): flag 0.x minor bumps as breaking (semver initial-development rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isMajorVersionBump only compared the numeric major component, so a bump like 0.1.0 -> 0.2.0 was reported as a non-breaking upgrade. Under semver's "initial development" clause — honoured in practice by npm's caret range, Cargo, and Composer — a minor bump while the major is 0 is breaking. For a supply-chain risk tool, silently marking these common pre-1.0 upgrades as safe undersells the [MAJOR] risk signal. Now while the major is 0, an increase in the minor component is flagged as a major/breaking bump; 0.x patch bumps stay non-breaking, and >=1.0 behaviour is unchanged. Parsing is factored into a small parseVersion helper that tolerates a leading "v" and pre-release/build suffixes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QtAD7yBCFLNhvjcrWBtCQ4 --- src/__tests__/diff.test.ts | 23 +++++++++++++++++++++++ src/diff.ts | 32 ++++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..6768232 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -69,6 +69,29 @@ describe('diff', () => { expect(report.upgraded[0].isMajorBump).toBe(true); }); + it('flags a 0.x minor bump as breaking (semver initial-development rule)', () => { + // Under semver's 0.x clause — honoured by npm's caret, Cargo, Composer — + // a minor bump while major is 0 is a breaking change. + const a = makesbom([{ name: 'ky', version: '0.1.0' }]); + const b = makesbom([{ name: 'ky', version: '0.2.0' }]); + const report = diff(a, b); + expect(report.upgraded[0].isMajorBump).toBe(true); + }); + + it('does not flag a 0.x patch bump as breaking', () => { + const a = makesbom([{ name: 'ky', version: '0.2.0' }]); + const b = makesbom([{ name: 'ky', version: '0.2.1' }]); + const report = diff(a, b); + expect(report.upgraded[0].isMajorBump).toBe(false); + }); + + it('flags a 0.x -> 1.x graduation as breaking', () => { + const a = makesbom([{ name: 'ky', version: '0.9.0' }]); + const b = makesbom([{ name: 'ky', version: '1.0.0' }]); + const report = diff(a, b); + expect(report.upgraded[0].isMajorBump).toBe(true); + }); + it('detects new CVEs', () => { const cve = { id: 'CVE-2021-44228', affects: 'pkg:npm/log4j@2.14.1', severity: 'critical' as const }; const a = makesbom([]); diff --git a/src/diff.ts b/src/diff.ts index 6540b50..62ab040 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -71,12 +71,32 @@ function buildComponentMap(components: Component[]): Map { } /** - * Returns true if the major version changed (semver-style). - * Handles versions like "1.2.3", "2.0.0-beta", etc. + * Returns true if an upgrade crosses a breaking-change boundary under semver. + * + * For 1.0.0 and above that means the major component increased + * (e.g. 1.4.0 -> 2.0.0). For 0.x releases semver's "initial development" + * clause says anything MAY change at any time, and the wider ecosystem + * (npm's `^0.2.0` caret, Cargo, Composer, ...) treats a *minor* bump as + * breaking. So while the major is 0, a change in the minor component + * (e.g. 0.1.0 -> 0.2.0) is also flagged as major/breaking. Without this, + * breaking upgrades of the many pre-1.0 packages in a typical dependency + * tree were silently reported as safe. + * + * Handles versions like "1.2.3", "2.0.0-beta", "v1.0.0", etc. Downgrades + * are not treated as bumps here. */ function isMajorVersionBump(from: string, to: string): boolean { - const fromMajor = parseInt(from.replace(/^[^0-9]*/, ''), 10); - const toMajor = parseInt(to.replace(/^[^0-9]*/, ''), 10); - if (isNaN(fromMajor) || isNaN(toMajor)) return false; - return toMajor > fromMajor; + const f = parseVersion(from); + const t = parseVersion(to); + if (!f || !t) return false; + if (t.major !== f.major) return t.major > f.major; + if (f.major === 0 && t.minor !== f.minor) return t.minor > f.minor; + return false; +} + +/** Parse the leading major.minor out of a version string, tolerating a "v" prefix and pre-release/build suffixes. */ +function parseVersion(v: string): { major: number; minor: number } | null { + const match = v.replace(/^[^0-9]*/, '').match(/^(\d+)(?:\.(\d+))?/); + if (!match) return null; + return { major: parseInt(match[1], 10), minor: match[2] ? parseInt(match[2], 10) : 0 }; }