Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/dep-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions packages/dep-check/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions packages/dep-check/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/dep-check/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
13 changes: 12 additions & 1 deletion packages/dep-check/src/checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}

/**
Expand Down
32 changes: 32 additions & 0 deletions packages/dep-check/test/checks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down