From 65fa2f3ab92d4af8205f8f0100dfaeeb4cb184b1 Mon Sep 17 00:00:00 2001 From: paulohenriquevn Date: Thu, 27 Aug 2026 09:43:25 -0300 Subject: [PATCH] fix(dep-check): a workspace sibling belongs in a per-package run, not the global override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor leg writes one global override per sibling. When that sibling lives in the workspace under test, the override replaces the workspace link with a published version — a pairing that exists nowhere. In development the link is used; published, `workspace:^` is rewritten to the CURRENT local version, never an old one. Measured on theokit#526: `@theokit/http` 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. Defect #4 exactly, in a new place, and it blocked a release PR while doing it. Dropping those floors would have been the wrong fix. `@theokit/agents` does declare that range and nothing verifies it, and theokit-di#44 was found by pinning a workspace sibling and running the suite. So they move to the per-package runs, where the floor is installed and only the packages that declare it are built. Coverage comes out strictly larger, because one override could only ever test one floor per sibling. theokit-di went from a single pinned 0.2.0 to two separate claims: 0.1.1 for di-agent and 0.2.0 for orm. Refs #22 --- .github/workflows/dep-check.yml | 2 +- CHANGELOG.md | 21 +++++++++++++ packages/dep-check/index.mjs | 29 +++++++++++++++-- packages/dep-check/package-lock.json | 4 +-- packages/dep-check/package.json | 2 +- packages/dep-check/src/checks.mjs | 24 ++++++++++++++ packages/dep-check/test/checks.test.mjs | 42 +++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 6 deletions(-) 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