diff --git a/.github/workflows/dep-check.yml b/.github/workflows/dep-check.yml index a3f09a0..7c5edca 100644 --- a/.github/workflows/dep-check.yml +++ b/.github/workflows/dep-check.yml @@ -32,7 +32,7 @@ on: TOOL is a semver artifact, so a behaviour change is a version bump somebody reviewed. The pin lives here rather than in eleven callers. type: string - default: '0.8.0' + default: '0.9.0' run-floor-check: description: | Also run the suite against the BOTTOM of every declared sibling range, not only diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf1ef8..5ac1b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [dep-check 0.9.0] - 2026-08-27 + +### Fixed + +- Check C distinguishes a range that is **ahead** of the registry from one that is behind it. Both + miss `latest`, and they are opposite problems: behind means the range stopped at an older major, + ahead means its floor is above everything published, so the package installs nowhere. The second + was reported as `0 majors behind` — a reader scanning the column saw a number that reads as + "roughly up to date" for a package no consumer can install (#24) + + It has a name of its own now, `unpublished`, and sorts above `contract`: a range the registry + cannot satisfy at all is worse than a stale one. It is the expected state mid-way through a + two-release change — a satellite declaring the floor it will need before the sibling publishes — + and a defect if it outlives one, which is exactly the distinction the label has to carry. + + ## [dep-check 0.8.0] - 2026-08-27 ### Fixed diff --git a/packages/dep-check/index.mjs b/packages/dep-check/index.mjs index 739476d..c71f9ea 100755 --- a/packages/dep-check/index.mjs +++ b/packages/dep-check/index.mjs @@ -172,16 +172,24 @@ async function commandRegistry(root) { const findings = []; for (const ref of refs) { const drift = ceilingDrift({ range: ref.range, latest: latest.get(ref.dep) }); - if (drift) findings.push({ ...ref, ...drift, severity: ref.field === "peerDependencies" ? "contract" : "behind" }); + if (!drift) continue; + // `ahead` outranks the peer/dependency distinction: a range the registry cannot satisfy at + // all means the package installs nowhere, which is worse than a stale contract. + const severity = drift.direction === "ahead" ? "unpublished" : ref.field === "peerDependencies" ? "contract" : "behind"; + findings.push({ ...ref, ...drift, severity }); } // A stale peer is a broken install contract for every consumer; a stale dependency // is just being a version behind, which is ordinary and what Renovate is for. - findings.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === "contract" ? -1 : 1)); + const rank = { unpublished: 0, contract: 1, behind: 2 }; + findings.sort((a, b) => rank[a.severity] - rank[b.severity]); report({ title: "C) declared range vs published latest", findings, - note: " `contract` = a peer range that no longer admits latest: consumers cannot install this combination.\n `behind` = an ordinary dependency one or more versions back.", - columns: (f) => `[${f.severity}] ${f.pkg.padEnd(26)} ${f.dep.padEnd(22)} ${f.range.padEnd(18)} latest ${f.latest} (${f.majorsBehind} major${f.majorsBehind === 1 ? "" : "s"} behind)`, + note: " `unpublished` = the range's floor is ABOVE latest: no published version satisfies it, so this\n package installs nowhere until the sibling publishes. Expected mid-way through a\n two-release change, and a defect if it outlives one.\n `contract` = a peer range that no longer admits latest: consumers cannot install this combination.\n `behind` = an ordinary dependency one or more versions back.", + columns: (f) => + f.direction === "ahead" + ? `[${f.severity}] ${f.pkg.padEnd(26)} ${f.dep.padEnd(22)} ${f.range.padEnd(18)} latest ${f.latest} (nothing published satisfies this yet)` + : `[${f.severity}] ${f.pkg.padEnd(26)} ${f.dep.padEnd(22)} ${f.range.padEnd(18)} latest ${f.latest} (${f.majorsBehind} major${f.majorsBehind === 1 ? "" : "s"} behind)`, }); return 0; // never blocks — see the header } diff --git a/packages/dep-check/package-lock.json b/packages/dep-check/package-lock.json index 312920a..b4b2b56 100644 --- a/packages/dep-check/package-lock.json +++ b/packages/dep-check/package-lock.json @@ -1,12 +1,12 @@ { "name": "@theokit/dep-check", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@theokit/dep-check", - "version": "0.8.0", + "version": "0.9.0", "license": "Apache-2.0", "dependencies": { "semver": "^7.7.2" diff --git a/packages/dep-check/package.json b/packages/dep-check/package.json index acb52e8..e0774c7 100644 --- a/packages/dep-check/package.json +++ b/packages/dep-check/package.json @@ -1,6 +1,6 @@ { "name": "@theokit/dep-check", - "version": "0.8.0", + "version": "0.9.0", "description": "The ecosystem dependency gate: does a package's declared range still describe the sibling it ships against? Four checks, kept apart by what they need to answer and therefore by whether they may fail a build.", "type": "module", "engines": { diff --git a/packages/dep-check/src/checks.mjs b/packages/dep-check/src/checks.mjs index a59b488..828b9de 100644 --- a/packages/dep-check/src/checks.mjs +++ b/packages/dep-check/src/checks.mjs @@ -55,7 +55,18 @@ export function ceilingDrift({ range, latest }) { // pull request against every consumer. if (semver.prerelease(latest)) return null; if (semver.satisfies(latest, range)) return null; - return { range, latest, majorsBehind: majorsBetween(range, latest) }; + // A range can miss `latest` from either side, and the two are opposite problems. + // + // BEHIND is the ordinary one: the range stopped at an older major and the world moved on. + // AHEAD happens during a two-release change — a satellite declares the floor it will need + // before the version exists, so nobody can install it until the other package publishes. + // Reporting that as `0 majors behind` describes its mirror image: a reader scanning the + // column sees a number that reads as "roughly up to date" for a package that installs nowhere. + const floor = semver.minVersion(range); + if (floor && semver.gt(floor, latest)) { + return { range, latest, direction: "ahead" }; + } + return { range, latest, direction: "behind", majorsBehind: majorsBetween(range, latest) }; } /** diff --git a/packages/dep-check/test/checks.test.mjs b/packages/dep-check/test/checks.test.mjs index 0ff097e..795ea7c 100644 --- a/packages/dep-check/test/checks.test.mjs +++ b/packages/dep-check/test/checks.test.mjs @@ -195,6 +195,38 @@ describe("groupUntestedFloors — the extra runs needed to exercise what the int }); }); +describe("ceilingDrift — a range ahead of the registry is not a range behind it", () => { + // A two-release change declares the new floor before the version exists: the satellite says + // `>=4.60.0` while the registry is at `4.59.0`. That is a real, temporary state — the package + // cannot be installed by anyone until the SDK publishes — and the gate should say so. + // + // It did say something, and it said the opposite: `0 majors behind`, for a range that is ahead. + // The measurement was right and the label described its mirror image. + + it("test_reports_a_range_the_registry_cannot_satisfy_yet_as_ahead", () => { + const drift = ceilingDrift({ range: ">=4.60.0", latest: "4.59.0" }); + expect(drift).toBeTruthy(); + expect(drift.direction).toBe("ahead"); + }); + + it("test_still_reports_an_ordinary_stale_range_as_behind", () => { + const drift = ceilingDrift({ range: "^3.0.0", latest: "4.59.0" }); + expect(drift.direction).toBe("behind"); + expect(drift.majorsBehind).toBe(1); + }); + + it("test_says_nothing_when_the_range_admits_latest", () => { + expect(ceilingDrift({ range: ">=4.0.0", latest: "4.59.0" })).toBeNull(); + }); + + it("test_an_ahead_range_does_not_claim_a_majors_behind_count", () => { + // `0 majors behind` on a range that is ahead is the label that made this worth fixing: + // a reader scanning the column sees a number that says "up to date, roughly". + const drift = ceilingDrift({ range: ">=4.60.0", latest: "4.59.0" }); + expect(drift.majorsBehind).toBeUndefined(); + }); +}); + describe("unpublishedSiblings — what check D cannot get from the registry yet", () => { // A version pull request bumps a package and a sibling it depends on in the same cut. // `pnpm pack` rewrites `workspace:^` to the NEW local version, correctly, and the registry