diff --git a/.github/workflows/dep-check.yml b/.github/workflows/dep-check.yml index 3610f0a..a3f09a0 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.7.0' + default: '0.8.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 913a68e..9bf1ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/dep-check/index.mjs b/packages/dep-check/index.mjs index 70c2998..739476d 100755 --- a/packages/dep-check/index.mjs +++ b/packages/dep-check/index.mjs @@ -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"; @@ -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 }); } @@ -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()); } diff --git a/packages/dep-check/package-lock.json b/packages/dep-check/package-lock.json index dd83377..312920a 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.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@theokit/dep-check", - "version": "0.7.0", + "version": "0.8.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 5eece1f..acb52e8 100644 --- a/packages/dep-check/package.json +++ b/packages/dep-check/package.json @@ -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": { diff --git a/packages/dep-check/src/checks.mjs b/packages/dep-check/src/checks.mjs index 3a26c24..a59b488 100644 --- a/packages/dep-check/src/checks.mjs +++ b/packages/dep-check/src/checks.mjs @@ -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. * diff --git a/packages/dep-check/test/checks.test.mjs b/packages/dep-check/test/checks.test.mjs index 9e6cba6..0ff097e 100644 --- a/packages/dep-check/test/checks.test.mjs +++ b/packages/dep-check/test/checks.test.mjs @@ -7,6 +7,7 @@ import { isSibling, rangeFloor, groupUntestedFloors, + pinnableSiblings, sharedFloor, unpublishedSiblings, untestedFloors, @@ -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