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.7.0'
default: '0.8.0'
run-floor-check:
description: |
Also run the suite against the BOTTOM of every declared sibling range, not only
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [dep-check 0.8.0] - 2026-08-27

### Fixed

- A sibling that lives in the workspace under test is no longer written into the **global** floor
override. The override is one value for the whole tree, so forcing a published version over a
workspace link failed the packages that consume it through `workspace:` and never see an old one.
Measured on `usetheokit/theokit`: `@theokit/http` was pinned at `0.4.0` — the floor of the
`>=0.1.0-alpha.0` that `@theokit/agents` declares — and `packages/theo`, which declares
`workspace:^` and claims nothing about `0.4.0`, failed to build against a version it has never
been paired with. That is the defect #4 was, in a new place (#22)

### Changed

- Those floors are not dropped, they move to the per-package runs, where the floor is installed and
only the packages that actually declare it are built. Dropping them would have cost a real
finding: theokit-di#44 was found exactly this way. Coverage is strictly larger than before —
`theokit-di` now exercises `@theokit/di@0.1.1` for `di-agent` and `@theokit/di@0.2.0` for `orm`
as separate claims, where the global override could only ever test one of them (#22)


### Fixed

- `v1` follows `main`, not only releases. The release moves it after a publish, which covers a
Expand Down
29 changes: 27 additions & 2 deletions packages/dep-check/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* none of the other three mean anything either.
*/
import { parseArgs } from "node:util";
import { ceilingDrift, consumersLeftBehind, groupUntestedFloors, installedDrift, isSibling, rangeFloor, sharedFloor, unpublishedSiblings, untestedFloors } from "./src/checks.mjs";
import { ceilingDrift, consumersLeftBehind, groupUntestedFloors, installedDrift, isSibling, pinnableSiblings, rangeFloor, sharedFloor, unpublishedSiblings, untestedFloors } from "./src/checks.mjs";
import { findPublishablePackages, resolveInstalledVersion, siblingReferences } from "./src/ecosystem.mjs";
import { consumersOf, discoverEcosystemPackages, latestVersion, packument, publishedVersions } from "./src/registry.mjs";
import { detectBuildScript, detectPackageManager, pinOverrides } from "./src/package-manager.mjs";
Expand Down Expand Up @@ -350,7 +350,14 @@ async function commandImpact(root) {
*/
async function lowestFloors(root) {
const ranges = new Map();
for (const ref of collectReferences(root)) {
// A sibling that lives in this workspace is not pinned: the override would replace the local
// link with a published version, which is a pairing that exists nowhere. Its declared range is
// still checked — by D, which installs the packed tarball the way a consumer would.
const members = findPublishablePackages(root).map((p) => p.manifest.name);
const all = collectReferences(root);
const pinnable = pinnableSiblings(all, members);
const perPackageOnly = all.filter((r) => !pinnable.includes(r));
for (const ref of pinnable) {
if (!ranges.has(ref.dep)) ranges.set(ref.dep, []);
ranges.get(ref.dep).push({ pkg: ref.pkg, range: ref.range });
}
Expand Down Expand Up @@ -383,6 +390,24 @@ async function lowestFloors(root) {
const who = gap.packages.length === 1 ? gap.packages[0] : `${gap.packages.length} packages (${gap.packages.join(", ")})`;
console.error(` note: ${who} declare${gap.packages.length === 1 ? "s" : ""} ${gap.dep} ${gap.range}, whose floor ${gap.version} is NOT exercised — the leg installs ${gap.tested}, the bottom of the intersection with the other declared ranges`);
}
// A sibling that lives in this workspace does not go into the GLOBAL override — the override is
// one value for the whole tree, and forcing a published version over a workspace link fails the
// packages that consume it via `workspace:` and never see an old one. Measured on theokit#526:
// `@theokit/http` was pinned at `0.4.0`, the floor of the `>=0.1.0-alpha.0` that
// `@theokit/agents` declares, and `packages/theo` — which declares `workspace:^` and claims
// nothing about `0.4.0` — failed to build. Defect #4 exactly, in a new place.
//
// The claim is still worth checking, and it is checked: these go to the PER-PACKAGE runs, where
// that floor is installed and only the packages that actually declare it are built. That is how
// theokit-di#44 was found, so dropping them entirely would have cost a real finding.
for (const ref of perPackageOnly) {
if (!ref.range || /^(workspace|file|link|portal):/.test(ref.range)) continue;
const versions = await publishedVersions(ref.dep);
const claims = rangeFloor(ref.range, versions);
if (!claims) continue;
untested.push({ dep: ref.dep, pkg: ref.pkg, range: ref.range, claims, tested: "(not pinned globally)" });
console.error(` note: ${ref.pkg} declares ${ref.dep} ${ref.range} and ${ref.dep} lives in this workspace — floor ${claims} goes to its own run rather than a global override`);
}
lowestFloors.lastUntested = untested;
return Object.fromEntries([...floors.entries()].sort());
}
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.7.0",
"version": "0.8.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
24 changes: 24 additions & 0 deletions packages/dep-check/src/checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,30 @@ export function rangeFloor(range, publishedVersions) {
return admitted.length ? semver.sort(admitted)[0] : null;
}

/**
* The sibling references a floor override may legitimately pin.
*
* A sibling that lives in THIS workspace is not one of them. Pinning it replaces the workspace
* link with a published version, and that combination exists nowhere: in development the link is
* used, and once published `workspace:^` is rewritten to the CURRENT local version, never an old
* one. Measured on usetheokit/theokit#526 — `@theokit/http` lives at `packages/http` there, the
* override installed `0.4.0` over it, and `packages/theo` failed to build against a version it
* has never been paired with:
*
* error TS2724: '"@theokit/http"' has no exported member named 'createDecoratorHandler'
*
* The declared range is still a claim worth checking — it is a promise to consumers outside this
* repository. The check that tests it is D, which installs the packed tarball the way a consumer
* would, against what the registry actually serves. Not an override that overwrites a sibling
* with its own past.
*
* An empty member list pins everything: failing open here would make the leg a silent no-op.
*/
export function pinnableSiblings(references, workspaceMembers) {
const members = new Set(workspaceMembers ?? []);
return references.filter((r) => !members.has(r.dep));
}

/**
* The extra runs needed to exercise the floors the intersection cannot reach.
*
Expand Down
42 changes: 42 additions & 0 deletions packages/dep-check/test/checks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isSibling,
rangeFloor,
groupUntestedFloors,
pinnableSiblings,
sharedFloor,
unpublishedSiblings,
untestedFloors,
Expand Down Expand Up @@ -108,6 +109,47 @@ describe("rangeFloor — check B: which published version is the bottom of the r
});
});

describe("pinnableSiblings — a workspace member is not something to pin from the registry", () => {
// Measured on usetheokit/theokit#526. `@theokit/http` lives in that workspace at packages/http,
// and `pin-floors` replaced the local link with `0.4.0` from the registry — the floor of the
// `>=0.1.0-alpha.0` that `@theokit/agents` declares. `packages/theo` then failed to build:
//
// error TS2724: '"@theokit/http"' has no exported member named 'createDecoratorHandler'
//
// Nobody anywhere has that combination. In development the workspace link is used; published,
// `workspace:^` is rewritten to the CURRENT local version, never an old one. The declared range
// is a promise to outside consumers, and the check that tests it is D — install the tarball as
// a consumer would — not an override that overwrites a sibling with its own past.
const published = { "@theokit/http": ["0.4.0", "1.0.0"], "@theokit/ui": ["1.1.0", "1.3.2"] };

it("test_does_not_pin_a_sibling_that_lives_in_this_workspace", () => {
const members = ["@theokit/http"];
expect(pinnableSiblings([{ dep: "@theokit/http", range: ">=0.1.0-alpha.0" }], members)).toEqual([]);
});

it("test_still_pins_a_sibling_that_only_comes_from_the_registry", () => {
// `@theokit/ui` is declared by this repository and built elsewhere. Its floor is exactly what
// the leg exists to exercise.
const out = pinnableSiblings([{ dep: "@theokit/ui", range: ">=1.1.0" }], ["@theokit/http"]);
expect(out.map((r) => r.dep)).toEqual(["@theokit/ui"]);
});

it("test_keeps_the_external_ones_when_both_kinds_are_declared", () => {
const out = pinnableSiblings(
[{ dep: "@theokit/http", range: ">=0.1.0" }, { dep: "@theokit/ui", range: ">=1.1.0" }],
["@theokit/http"],
);
expect(out.map((r) => r.dep)).toEqual(["@theokit/ui"]);
});

it("test_an_empty_workspace_list_pins_everything_rather_than_nothing", () => {
// A repository with no publishable members must not silently stop pinning. Failing open here
// would turn the floor leg into a no-op with no signal that it had.
const out = pinnableSiblings([{ dep: "@theokit/ui", range: ">=1.1.0" }], []);
expect(out.map((r) => r.dep)).toEqual(["@theokit/ui"]);
});
});

describe("groupUntestedFloors — the extra runs needed to exercise what the intersection cannot", () => {
it("test_groups_packages_that_share_the_same_unexercised_floor_into_one_run", () => {
// `theokit-plugins` has fourteen packages declaring `theokit >=0.50.1`. Fourteen separate
Expand Down