From 7c75f2c49cb8f0c0b8b28cce0952f2881f70a8a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:31:47 +0000 Subject: [PATCH] test(spec): the retired-after census reads a prerelease package label on its release triple Changesets pre mode writes `18.0.0-next.0` into packages/spec/package.json on the Version Packages PR. The census parsed the label with the same strict x.y.z reader it uses for census versions and stamps, so three of its five cases threw `not a stable x.y.z version` and spec's local test project went red. The census now compares against the label's release triple (`18.0.0-next.N` reads as `18.0.0`), the reading the artifact door gives the runtime label. Census versions and retiredAfter stamps stay strict stable x.y.z, and a label that is neither x.y.z nor x.y.z-PRERELEASE still throws. A unit pin covers the rule. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf --- .../conversions/retired-after.census.test.ts | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/conversions/retired-after.census.test.ts b/packages/spec/src/conversions/retired-after.census.test.ts index 61df3941bea..0feca20e455 100644 --- a/packages/spec/src/conversions/retired-after.census.test.ts +++ b/packages/spec/src/conversions/retired-after.census.test.ts @@ -32,6 +32,23 @@ import { ALL_CONVERSIONS } from './registry.js'; * version from the census's last release up to the label — it landed either * before the bump or after it, and only the new tarball can say which. * Refreshing the census after the publish closes the tolerance again. + * + * The label is read on its RELEASE TRIPLE. Changesets pre mode writes a + * prerelease label (`18.0.0-next.0`) into this package on the Version Packages + * PR, and onto `main` once that lands, so every comparison against the label + * uses `x.y.z` with the prerelease suffix dropped: `18.0.0-next.N` reads as + * `18.0.0`. That is the reading the artifact door gives the runtime label (it + * compares `x.y.z` triples and tolerates a suffix), and on a stable label it is + * the label itself, so no assertion changes meaning there. Under a prerelease + * label the census never records that label's tarball (it holds stable + * releases only), so the release-pending tolerance above stays open, spanning + * `[last censused release, release triple]`, until the stable release is + * published and censused, when the PUBLISHED rule re-judges every stamp. Which + * stamp a retirement landing under a prerelease label should carry is the + * artifact door's window question, and this file follows that rule rather + * than settling it. Only the label gets this reading: a census version and a + * `retiredAfter` stamp stay stable `x.y.z`, and a label that is neither + * `x.y.z` nor `x.y.z-PRERELEASE` still throws. */ type Triple = [number, number, number]; @@ -69,6 +86,22 @@ function cmp(a: string, b: string): number { return 0; } +/** + * The release triple of a package label: `x.y.z` as is, `x.y.z-PRERELEASE` + * with the suffix dropped (`18.0.0-next.0` reads as `18.0.0`). Anything else + * throws. See the file docblock for why the label alone is read this way. + */ +function releaseTriple(label: string): string { + const m = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(label); + if (!m) throw new Error(`not an x.y.z or x.y.z-prerelease package label: '${label}'`); + return m[1]!; +} + +/** The label as every comparison below reads it. */ +const RELEASE = releaseTriple(LABEL); +/** The label as a failure message names it. */ +const LABEL_SHOWN = LABEL === RELEASE ? `'${LABEL}'` : `'${LABEL}' (release triple '${RELEASE}')`; + const LAST_CENSUSED = CENSUS.releases.at(-1)!.version; /** @@ -91,7 +124,7 @@ describe('retiredAfter census — every retired entry stamped from the published for (let i = 1; i < versions.length; i++) { expect(cmp(versions[i - 1]!, versions[i]!), `${versions[i - 1]} < ${versions[i]}`).toBe(-1); } - expect(cmp(LAST_CENSUSED, LABEL), `census (${LAST_CENSUSED}) is ahead of the label (${LABEL})`).toBeLessThanOrEqual(0); + expect(cmp(LAST_CENSUSED, RELEASE), `census (${LAST_CENSUSED}) is ahead of the label ${LABEL_SHOWN}`).toBeLessThanOrEqual(0); for (const r of CENSUS.releases) { expect(r.integrity).toMatch(/^sha512-/); expect([...r.retired].sort()).toEqual(r.retired); @@ -102,7 +135,7 @@ describe('retiredAfter census — every retired entry stamped from the published expect(RETIRED.length).toBeGreaterThan(0); for (const c of RETIRED) { expect(c.retiredAfter, c.id).toMatch(/^\d+\.\d+\.\d+$/); - expect(cmp(c.retiredAfter!, LABEL), `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL}`).toBeLessThanOrEqual(0); + expect(cmp(c.retiredAfter!, RELEASE), `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL_SHOWN}`).toBeLessThanOrEqual(0); } }); @@ -126,17 +159,44 @@ describe('retiredAfter census — every retired entry stamped from the published }); it('every UNPUBLISHED entry carries the package label (the release-pending range while the census trails it)', () => { - const pending = cmp(LABEL, LAST_CENSUSED) > 0; + const pending = cmp(RELEASE, LAST_CENSUSED) > 0; const wrong: string[] = []; for (const c of RETIRED) { if (publishedRetiredAfter(c.id) !== null) continue; const v = c.retiredAfter!; - if (!pending && v !== LABEL) { - wrong.push(`${c.id}: retiredAfter '${v}', but no published tarball retires it, so it takes the label '${LABEL}'`); - } else if (pending && (cmp(v, LAST_CENSUSED) < 0 || cmp(v, LABEL) > 0)) { - wrong.push(`${c.id}: retiredAfter '${v}' is outside [${LAST_CENSUSED}, ${LABEL}] — refresh the census (${REGEN})`); + if (!pending && v !== RELEASE) { + wrong.push(`${c.id}: retiredAfter '${v}', but no published tarball retires it, so it takes the label ${LABEL_SHOWN}`); + } else if (pending && (cmp(v, LAST_CENSUSED) < 0 || cmp(v, RELEASE) > 0)) { + wrong.push(`${c.id}: retiredAfter '${v}' is outside [${LAST_CENSUSED}, ${RELEASE}] — refresh the census (${REGEN})`); } } expect(wrong).toEqual([]); }); }); + +describe('the package label is read on its release triple', () => { + it('a stable label is its own release triple', () => { + expect(releaseTriple('17.7.0')).toBe('17.7.0'); + expect(releaseTriple('18.0.0')).toBe('18.0.0'); + }); + + it('a prerelease label reads as its release triple', () => { + expect(releaseTriple('18.0.0-next.0')).toBe('18.0.0'); + expect(releaseTriple('18.0.0-next.12')).toBe('18.0.0'); + expect(releaseTriple('18.1.0-rc.1')).toBe('18.1.0'); + // ...and so compares at, never above, the release it precedes. + expect(cmp('17.7.0', releaseTriple('18.0.0-next.0'))).toBe(-1); + expect(cmp('18.0.0', releaseTriple('18.0.0-next.0'))).toBe(0); + }); + + it('a malformed label still throws, naming the label', () => { + for (const bad of ['', 'next', '18', '18.0', 'v18.0.0', '18.0.0-', '18.0.0-next..0', '18.0.0.0', ' 18.0.0']) { + expect(() => releaseTriple(bad), JSON.stringify(bad)).toThrow(`'${bad}'`); + } + }); + + it('the release-triple reading is the label\'s alone: a census version or a stamp stays stable x.y.z', () => { + expect(() => triple('18.0.0-next.0')).toThrow(`'18.0.0-next.0'`); + expect(() => cmp('18.0.0-next.0', '17.7.0')).toThrow(`'18.0.0-next.0'`); + }); +});