From 166635e4c0ae244c9bf4e2eee449f9053b6d9c1c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:45:22 +0800 Subject: [PATCH] test(create-objectstack): satisfiesCaret reads a prerelease floor, so the runtime-image agreement holds on a cut-rc version pass In pre mode a cut-rc version pass makes the scaffolder's own version 18.0.0-rc.N, so it writes ^18.0.0-rc.N for @objectstack/cli. The test-local helper accepted only a plain ^X.Y.Z, answered false for every version, and the fixture precondition and the agreement test would both have gone red on the release PR, blaming the fixture and the scaffolder. The helper now follows semver caret semantics for a prerelease floor: same major, at or above the floor, a release outranks its own prereleases, and a prerelease version is admitted only on a prerelease floor's own X.Y.Z. A range it cannot read throws instead of answering false. New unit pins cover the prerelease floor, the plain-floor controls and the refusal. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .../src/runtime-image.test.ts | 93 +++++++++++++++++-- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/create-objectstack/src/runtime-image.test.ts b/packages/create-objectstack/src/runtime-image.test.ts index 776a350c439..de6880de6d4 100644 --- a/packages/create-objectstack/src/runtime-image.test.ts +++ b/packages/create-objectstack/src/runtime-image.test.ts @@ -43,22 +43,67 @@ function cliRange(dir: string): string { return pkg.devDependencies['@objectstack/cli']; } +/** + * semver precedence of two prerelease tails (`rc.2` vs `rc.10`); `undefined` + * is a release, which outranks every prerelease of the same `X.Y.Z`. + */ +function comparePrerelease(a: string | undefined, b: string | undefined): number { + if (a === b) return 0; + if (a === undefined) return 1; + if (b === undefined) return -1; + const as = a.split('.'); + const bs = b.split('.'); + for (let i = 0; i < Math.max(as.length, bs.length); i++) { + if (as[i] === undefined) return -1; + if (bs[i] === undefined) return 1; + if (as[i] === bs[i]) continue; + const aNum = /^\d+$/.test(as[i]); + const bNum = /^\d+$/.test(bs[i]); + if (aNum && bNum) return Number(as[i]) - Number(bs[i]); + if (aNum !== bNum) return aNum ? -1 : 1; + return as[i] < bs[i] ? -1 : 1; + } + return 0; +} + /** * Does `version` satisfy the caret `range` the scaffolder writes? Deliberately * a satisfies-check and not an equality one: the emitted package.json carries * `^X.Y.Z` and the tag names the version npm RESOLVED inside that range, so * equality would be the wrong agreement predicate and would pass only by * accident on a freshly published major. + * + * The floor may carry a prerelease tail. The scaffolder writes `^`, and a `cut-rc` version pass in pre mode makes that version + * `18.0.0-rc.N`, so the range becomes `^18.0.0-rc.N`. Caret semantics are + * semver's: the same major and at or above the floor, where a release outranks + * its own prereleases, so `18.1.2` satisfies `^18.0.0-rc.1`. A prerelease + * VERSION is admitted only on a prerelease floor's own `X.Y.Z`, so + * `18.1.0-rc.1` does not. + * + * A range it cannot read THROWS rather than answering `false`: a `false` here + * reads as the scaffolder's or the fixture's fault, which is how a range shape + * this helper did not model once failed on the release PR. Majors of 1 and up + * only; the scaffolder writes no range for a `0.x` version. */ function satisfiesCaret(range: string, version: string): boolean { - const r = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range); - const v = /^(\d+)\.(\d+)\.(\d+)/.exec(version); - if (!r || !v) return false; - const [rMaj, rMin, rPat] = r.slice(1).map(Number); - const [vMaj, vMin, vPat] = v.slice(1).map(Number); + const r = /^\^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(range); + if (!r || r[1] === '0') { + throw new Error( + `satisfiesCaret reads ^MAJOR.MINOR.PATCH[-PRERELEASE] with MAJOR >= 1, got "${range}"`, + ); + } + const v = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!v) return false; + const [rMaj, rMin, rPat] = r.slice(1, 4).map(Number); + const [vMaj, vMin, vPat] = v.slice(1, 4).map(Number); + const rPre = r[4]; + const vPre = v[4]; if (vMaj !== rMaj) return false; + if (vPre !== undefined && (rPre === undefined || vMin !== rMin || vPat !== rPat)) return false; if (vMin !== rMin) return vMin > rMin; - return vPat >= rPat; + if (vPat !== rPat) return vPat > rPat; + return comparePrerelease(vPre, rPre) >= 0; } /** Scaffold the blank template the way index.ts does, up to the install. */ @@ -261,6 +306,42 @@ describe('scaffolded Dockerfile runtime image tag (#9017)', () => { }); }); + // The agreement predicate on its own. The tests above call it only with the + // version this checkout carries, so a range shape the NEXT version pass + // produces is exercised nowhere else until that pass lands. A `cut-rc` pass + // in pre mode is the case: the scaffolder writes `^18.0.0-rc.N`. + describe('satisfiesCaret', () => { + it.each([ + ['^18.0.0-rc.1', '18.0.0-rc.2', true], + ['^18.0.0-rc.1', '18.1.2', true], + ['^18.0.0-rc.1', '18.0.0', true], + ['^18.0.0-rc.1', '17.9.0', false], + ['^18.0.0-rc.1', '19.0.0', false], + ['^18.0.0-rc.1', '18.0.0-rc.0', false], + ['^18.0.0-rc.1', '18.1.0-rc.1', false], + ])('reads a prerelease floor: %s admits %s is %s', (range, version, admits) => { + expect(satisfiesCaret(range, version)).toBe(admits); + }); + + it.each([ + ['^17.4.0', '17.4.0', true], + ['^17.4.0', '17.5.2', true], + ['^17.4.0', '17.3.9', false], + ['^17.4.0', '18.0.0', false], + ])('keeps the plain floor: %s admits %s is %s', (range, version, admits) => { + expect(satisfiesCaret(range, version)).toBe(admits); + }); + + it('admits a prerelease version only on a prerelease floor', () => { + expect(satisfiesCaret('^17.4.0', '17.5.0-rc.1')).toBe(false); + }); + + it('refuses a range it cannot read instead of answering false', () => { + expect(() => satisfiesCaret('~18.0.0', '18.0.1')).toThrow('"~18.0.0"'); + expect(() => satisfiesCaret('^0.2.3', '0.2.4')).toThrow('"^0.2.3"'); + }); + }); + describe('readResolvedCliVersion', () => { it('reads the installed CLI version', () => { installCli(dir, '17.4.2');