diff --git a/test/docs-set.test.ts b/test/docs-set.test.ts index 630061b..e7c5515 100644 --- a/test/docs-set.test.ts +++ b/test/docs-set.test.ts @@ -15,7 +15,7 @@ */ import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -55,6 +55,37 @@ function graded(files: Record, arms: readonly ArmName[]): Findin } } +/** + * Grade a control against a `package.json` that is this repository's own with only its `version` + * replaced, so the package-agreement controls say the same thing whatever version the tree is at. + * The gate reads a `0.0.x` version as not yet on the public registry and anything later as on it, + * so each control is graded on both sides of that line rather than on whichever side the tree + * happens to sit. + */ +function gradedAtVersion( + files: Record, + arms: readonly ArmName[], + version: string, +): Finding[] { + const dir = mkdtempSync(join(tmpdir(), "fhir-docs-control-")); + const manifestDir = mkdtempSync(join(tmpdir(), "fhir-docs-manifest-")); + try { + for (const [name, body] of Object.entries(files)) seed(dir, name, body); + const manifest = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")) as object; + const packageJsonPath = join(manifestDir, "package.json"); + writeFileSync(packageJsonPath, JSON.stringify({ ...manifest, version })); + return checkDocsSet(dir, { arms, packageJsonPath }); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(manifestDir, { recursive: true, force: true }); + } +} + +/** A `0.0.x` version, which the gate reads as not on the public registry. */ +const UNPUBLISHED_LINE = "0.0.11"; +/** A version past `0.0.x`, which the gate reads as on the public registry. */ +const PUBLISHED_LINE = "0.1.0"; + /** * Grade a control against an allow-list that declares nothing. * @@ -234,45 +265,61 @@ describe("every arm goes red on a seeded control", () => { expect(messages(findings)).toContain("every sidebar item must be a page id string"); }); + /** An installation page stating a Node range and exactly one registry claim. */ + const installationPage = (nodeRange: string, claim: string): Record => ({ + "installation.md": page( + "installation", + "Installation", + [ + "Install `@cosyte/fhir` with `pnpm add @cosyte/fhir`.", + "", + `It needs Node \`${nodeRange}\`, ships ESM, and also offers CommonJS.`, + "", + claim, + ].join("\n"), + ), + }); + const INSTALLABLE = "The package is installable from the public npm registry today."; + const NOT_INSTALLABLE = "The package is not installable from the public npm registry today."; + it("package-agreement: a Node range and a registry claim that disagree with package.json", () => { - const findings = graded( - { - "installation.md": page( - "installation", - "Installation", - [ - "Install `@cosyte/fhir` with `pnpm add @cosyte/fhir`.", - "", - "It needs Node `>=18.0.0`, ships ESM, and also offers CommonJS.", - "", - "The package is installable from the public npm registry today.", - ].join("\n"), - ), - }, + const findings = gradedAtVersion( + installationPage(">=18.0.0", INSTALLABLE), ["package-agreement"], + UNPUBLISHED_LINE, ); expect(messages(findings)).toContain('does not state the supported Node range ">=22.0.0"'); expect(messages(findings)).toContain("disagrees with package.json"); }); - it("package-agreement: the truthful page passes the same arm", () => { - const findings = graded( - { - "installation.md": page( - "installation", - "Installation", - [ - "Install `@cosyte/fhir` with `pnpm add @cosyte/fhir`.", - "", - "It needs Node `>=22.0.0`, ships ESM, and also offers CommonJS.", - "", - "The package is not installable from the public npm registry today.", - ].join("\n"), - ), - }, + it("package-agreement: the opposite registry claim disagrees once the version leaves 0.0.x", () => { + const findings = gradedAtVersion( + installationPage(">=22.0.0", NOT_INSTALLABLE), ["package-agreement"], + PUBLISHED_LINE, ); - expect(messages(findings)).toBe(""); + expect(messages(findings)).toContain("disagrees with package.json"); + }); + + it("package-agreement: the truthful page passes the same arm", () => { + expect( + messages( + gradedAtVersion( + installationPage(">=22.0.0", NOT_INSTALLABLE), + ["package-agreement"], + UNPUBLISHED_LINE, + ), + ), + ).toBe(""); + expect( + messages( + gradedAtVersion( + installationPage(">=22.0.0", INSTALLABLE), + ["package-agreement"], + PUBLISHED_LINE, + ), + ), + ).toBe(""); }); it("synthetic-identifiers: every identifying element is graded against the declaration", () => { diff --git a/test/release-readiness.test.ts b/test/release-readiness.test.ts index d6d4ba0..6e95b1d 100644 --- a/test/release-readiness.test.ts +++ b/test/release-readiness.test.ts @@ -1466,19 +1466,54 @@ describe("the exports map publishes exactly the certified subpaths", () => { describe("this tree performs no publication and makes none inevitable", () => { /** * The version measured on this tree when the audit was written. It is asserted rather than - * derived so that a bump ANYWHERE reds here: the point is that this change did not move it. - * `test/sanity.test.ts` separately asserts VERSION against package.json; this is the different - * question of whether either one MOVED, and the two coexist. + * derived so that a bump the audit did not certify reds here. `test/sanity.test.ts` separately + * asserts VERSION against package.json; this is the different question of whether either one + * MOVED, and the two coexist. */ const MEASURED_VERSION = "0.0.11"; - it("leaves package.json at the version this work measured", () => { - expect(PACKAGE_JSON.version).toBe(MEASURED_VERSION); + /** + * The one move the audit sanctions: the release it certified, read from the committed surface + * inventory rather than restated, and patches on that release's line. Changesets' version commit + * moves `package.json` and the `VERSION` export together, so the release commit reads the + * certified version and must pass here. A patch cannot change a public observable under the + * classification rule above, and the surface tests hold the certified surface exactly in every + * state, so a patch on the certified line keeps the certification. A new minor (or any other + * version) is a release the audit never certified, and it reds until the surface is certified + * for it. + */ + function isCertifiedMove(version: string): boolean { + const certified = /^(\d+)\.(\d+)\.(\d+)$/.exec(INVENTORY.certifiedFor); + const candidate = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (certified === null || candidate === null) return false; + return ( + candidate[1] === certified[1] && + candidate[2] === certified[2] && + Number(candidate[3]) >= Number(certified[3]) + ); + } + + const accountedFor = (version: string): boolean => + version === MEASURED_VERSION || isCertifiedMove(version); + + it("leaves package.json at the version this work measured, or on the release line it certified", () => { + expect( + accountedFor(PACKAGE_JSON.version), + `package.json reads ${PACKAGE_JSON.version}, which is neither the measured ${MEASURED_VERSION} nor on the ${INVENTORY.certifiedFor} line the audit certified`, + ).toBe(true); }); it("leaves the VERSION export agreeing with it", () => { expect(VERSION).toBe(PACKAGE_JSON.version); - expect(VERSION).toBe(MEASURED_VERSION); + expect(accountedFor(VERSION)).toBe(true); + }); + + it("has teeth: a version the audit neither measured nor certified is refused", () => { + expect(accountedFor(MEASURED_VERSION)).toBe(true); + expect(accountedFor(INVENTORY.certifiedFor)).toBe(true); + for (const uncertified of ["0.0.12", "0.0.10", "0.2.0", "1.0.0", "0.1.0-next.0", "0.1"]) { + expect(accountedFor(uncertified), uncertified).toBe(false); + } }); it("leaves every classified changeset still pending in .changeset/", () => { diff --git a/test/sanity.test.ts b/test/sanity.test.ts index 79cc352..3053331 100644 --- a/test/sanity.test.ts +++ b/test/sanity.test.ts @@ -25,7 +25,10 @@ describe("@cosyte/fhir scaffold", () => { expect(VERSION).toBe(pkg.version); }); - it("starts on the v0.0.x pre-alpha ladder", () => { - expect(VERSION).toMatch(/^0\.0\.\d+$/); + it("stays on the 0.x line, where a breaking change bumps the minor version", () => { + // The operator moved every package to 0.1.x, so the ladder this pins is the 0.x one: a plain + // `0.minor.patch`, never a 1.0, a major or a prerelease. The classification rule in + // `test/release-readiness.test.ts` is what keeps a break off the patch position. + expect(VERSION).toMatch(/^0\.\d+\.\d+$/); }); });