Skip to content

Commit 166635e

Browse files
hotlongclaude
andcommitted
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 <noreply@anthropic.com>
1 parent f11b5f2 commit 166635e

1 file changed

Lines changed: 87 additions & 6 deletions

File tree

‎packages/create-objectstack/src/runtime-image.test.ts‎

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,67 @@ function cliRange(dir: string): string {
4343
return pkg.devDependencies['@objectstack/cli'];
4444
}
4545

46+
/**
47+
* semver precedence of two prerelease tails (`rc.2` vs `rc.10`); `undefined`
48+
* is a release, which outranks every prerelease of the same `X.Y.Z`.
49+
*/
50+
function comparePrerelease(a: string | undefined, b: string | undefined): number {
51+
if (a === b) return 0;
52+
if (a === undefined) return 1;
53+
if (b === undefined) return -1;
54+
const as = a.split('.');
55+
const bs = b.split('.');
56+
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
57+
if (as[i] === undefined) return -1;
58+
if (bs[i] === undefined) return 1;
59+
if (as[i] === bs[i]) continue;
60+
const aNum = /^\d+$/.test(as[i]);
61+
const bNum = /^\d+$/.test(bs[i]);
62+
if (aNum && bNum) return Number(as[i]) - Number(bs[i]);
63+
if (aNum !== bNum) return aNum ? -1 : 1;
64+
return as[i] < bs[i] ? -1 : 1;
65+
}
66+
return 0;
67+
}
68+
4669
/**
4770
* Does `version` satisfy the caret `range` the scaffolder writes? Deliberately
4871
* a satisfies-check and not an equality one: the emitted package.json carries
4972
* `^X.Y.Z` and the tag names the version npm RESOLVED inside that range, so
5073
* equality would be the wrong agreement predicate and would pass only by
5174
* accident on a freshly published major.
75+
*
76+
* The floor may carry a prerelease tail. The scaffolder writes `^<its own
77+
* version>`, and a `cut-rc` version pass in pre mode makes that version
78+
* `18.0.0-rc.N`, so the range becomes `^18.0.0-rc.N`. Caret semantics are
79+
* semver's: the same major and at or above the floor, where a release outranks
80+
* its own prereleases, so `18.1.2` satisfies `^18.0.0-rc.1`. A prerelease
81+
* VERSION is admitted only on a prerelease floor's own `X.Y.Z`, so
82+
* `18.1.0-rc.1` does not.
83+
*
84+
* A range it cannot read THROWS rather than answering `false`: a `false` here
85+
* reads as the scaffolder's or the fixture's fault, which is how a range shape
86+
* this helper did not model once failed on the release PR. Majors of 1 and up
87+
* only; the scaffolder writes no range for a `0.x` version.
5288
*/
5389
function satisfiesCaret(range: string, version: string): boolean {
54-
const r = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range);
55-
const v = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
56-
if (!r || !v) return false;
57-
const [rMaj, rMin, rPat] = r.slice(1).map(Number);
58-
const [vMaj, vMin, vPat] = v.slice(1).map(Number);
90+
const r = /^\^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(range);
91+
if (!r || r[1] === '0') {
92+
throw new Error(
93+
`satisfiesCaret reads ^MAJOR.MINOR.PATCH[-PRERELEASE] with MAJOR >= 1, got "${range}"`,
94+
);
95+
}
96+
const v = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
97+
if (!v) return false;
98+
const [rMaj, rMin, rPat] = r.slice(1, 4).map(Number);
99+
const [vMaj, vMin, vPat] = v.slice(1, 4).map(Number);
100+
const rPre = r[4];
101+
const vPre = v[4];
59102
if (vMaj !== rMaj) return false;
103+
if (vPre !== undefined && (rPre === undefined || vMin !== rMin || vPat !== rPat)) return false;
60104
if (vMin !== rMin) return vMin > rMin;
61-
return vPat >= rPat;
105+
if (vPat !== rPat) return vPat > rPat;
106+
return comparePrerelease(vPre, rPre) >= 0;
62107
}
63108

64109
/** Scaffold the blank template the way index.ts does, up to the install. */
@@ -261,6 +306,42 @@ describe('scaffolded Dockerfile runtime image tag (#9017)', () => {
261306
});
262307
});
263308

309+
// The agreement predicate on its own. The tests above call it only with the
310+
// version this checkout carries, so a range shape the NEXT version pass
311+
// produces is exercised nowhere else until that pass lands. A `cut-rc` pass
312+
// in pre mode is the case: the scaffolder writes `^18.0.0-rc.N`.
313+
describe('satisfiesCaret', () => {
314+
it.each([
315+
['^18.0.0-rc.1', '18.0.0-rc.2', true],
316+
['^18.0.0-rc.1', '18.1.2', true],
317+
['^18.0.0-rc.1', '18.0.0', true],
318+
['^18.0.0-rc.1', '17.9.0', false],
319+
['^18.0.0-rc.1', '19.0.0', false],
320+
['^18.0.0-rc.1', '18.0.0-rc.0', false],
321+
['^18.0.0-rc.1', '18.1.0-rc.1', false],
322+
])('reads a prerelease floor: %s admits %s is %s', (range, version, admits) => {
323+
expect(satisfiesCaret(range, version)).toBe(admits);
324+
});
325+
326+
it.each([
327+
['^17.4.0', '17.4.0', true],
328+
['^17.4.0', '17.5.2', true],
329+
['^17.4.0', '17.3.9', false],
330+
['^17.4.0', '18.0.0', false],
331+
])('keeps the plain floor: %s admits %s is %s', (range, version, admits) => {
332+
expect(satisfiesCaret(range, version)).toBe(admits);
333+
});
334+
335+
it('admits a prerelease version only on a prerelease floor', () => {
336+
expect(satisfiesCaret('^17.4.0', '17.5.0-rc.1')).toBe(false);
337+
});
338+
339+
it('refuses a range it cannot read instead of answering false', () => {
340+
expect(() => satisfiesCaret('~18.0.0', '18.0.1')).toThrow('"~18.0.0"');
341+
expect(() => satisfiesCaret('^0.2.3', '0.2.4')).toThrow('"^0.2.3"');
342+
});
343+
});
344+
264345
describe('readResolvedCliVersion', () => {
265346
it('reads the installed CLI version', () => {
266347
installCli(dir, '17.4.2');

0 commit comments

Comments
 (0)