Skip to content

Commit e666636

Browse files
objectstack-fleet[bot]hotlongclaude
authored
test(create-objectstack): satisfiesCaret reads a prerelease floor, so the runtime-image agreement holds on a cut-rc version pass (#20581)
Fixes #20566 Clause-②: no ## What changed One file, tests only: `packages/create-objectstack/src/runtime-image.test.ts`. The scaffolder writes `^OWN_VERSION` for `@objectstack/cli`. A `cut-rc` version pass in pre mode makes that version `18.0.0-rc.N`, so the scaffolded range is `^18.0.0-rc.N`. The test-local helper `satisfiesCaret` accepted only a plain `^X.Y.Z`. On such a range it answered `false` for every version. So the fixture precondition and the agreement test would both go red on the RC version PR, and the failure would blame the fixture and the scaffolder. - **`satisfiesCaret` now follows semver caret semantics for a prerelease floor.** The version must have the same major and be at or above the floor. A release outranks its own prereleases, so `18.1.2` and `18.0.0` satisfy `^18.0.0-rc.1`. A prerelease version counts only on a prerelease floor's own `X.Y.Z`, so `18.1.0-rc.1` does not satisfy it. Prerelease precedence is in a small `comparePrerelease` beside it. - **A range the helper cannot read now throws instead of answering `false`.** The thrown message names the range. A `false` here reads as the scaffolder's or the fixture's fault, and that is how this defect would have presented. Majors of 1 and up only: `syncObjectStackDeps` writes no range for a `0.x` version. - **New pins** in `describe('satisfiesCaret')`: - Triage's grade: `^18.0.0-rc.1` is satisfied by `18.0.0-rc.2` and `18.1.2`, and not by `17.9.0` or `19.0.0`. - Three more prerelease-floor cases: `18.0.0` yes, `18.0.0-rc.0` no, `18.1.0-rc.1` no. - The plain-floor controls on `^17.4.0` (`17.4.0`, `17.5.2` yes; `17.3.9`, `18.0.0` no). - One case for a prerelease version against a plain floor. - The refusal: `~18.0.0` and `^0.2.3` throw, naming the range. `semver` was not used. It is not a `create-objectstack` devDependency at this ref (see H0 below), and triage's grade takes the "otherwise" branch in that case. ## Measurements All of these ran at `166635e4c0` (branch head), which the test/typecheck/lint/gate readings below cite. - **H0: `semver` is not usable from the package without adding a dependency.** `packages/create-objectstack/package.json` devDependencies are `@objectstack/spec`, `@types/node`, `tsup`, `typescript`, `vitest` and `yaml`. `semver` is a ROOT devDependency (`package.json`, used by two root scripts). From the package directory, `import.meta.resolve('semver')` does resolve at runtime, but only as a phantom through the root `node_modules/semver` link. It has no types: there is no `@types/semver` in the store and no `.d.ts` in `semver@7.8.5`, and the package's `strict` NodeNext `tsconfig.json` compiles this test file. So the import would resolve at runtime and fail the typecheck. That falls under triage's "otherwise" branch. (The typecheck failure is inferred from the absent declarations, not run.) - **H1: the helper as it stood answers the prerelease range wrongly.** The helper was extracted from `f11b5f20a` and compared with `semver.satisfies` (the root devDependency, used only as an oracle in a scratch script): - On `^18.0.0-rc.1` it returned `false` for `18.0.0-rc.2`, `18.1.2` and `18.0.0`, where semver says `true`. It also returned `true` for `^17.4.0` vs `17.5.0-rc.1`, where semver says `false`. That is 4 of 12 cases. - Over a 104,979-pair matrix (majors 17–19, twelve prerelease tails, plus build metadata), the old helper disagrees with semver on **4,754** pairs. The new helper at `166635e4c0` disagrees on **0**. - **The card's symptom, measured end to end.** It had not been run against an RC tree before. `packages/create-objectstack/package.json` was set to `"version": "18.0.0-rc.1"` through `scripts/ablation-replace.mjs`, and the suite ran twice: - The BASE copy of this test file: **2 failed | 15 passed**, exactly the two the card predicted. The precondition failed with `fixture defect: the planted @objectstack/cli 18.1.2 does not satisfy ^18.0.0-rc.1`, and the agreement test with `scaffolded Dockerfile pins :18.1.2 but the scaffolded package.json asks for "^18.0.0-rc.1"`. - This branch's file: **30 passed (30)**. - Restore proven: `package.json` blob equals HEAD (`49e071ff2a9d`), `git diff HEAD` is empty, the probe file is removed, and `git status --porcelain` shows 0 lines. - **H2: the suite is not platform-gated.** `runtime-image.test.ts` has no `skipIf`/`runIf` and ran on darwin here: 30/30. The one skipped file in the package run is `scaffold-e2e-boot-probe.test.ts`, which is Linux-only and untouched here. In CI this suite runs in `ci.yml` Test Core (`create-objectstack#test`). - **H3: ablation.** The prerelease branch was removed from the range regex (anchor hit x1 → x0, blob `de6880de6d49` → `2a83466c65c3`). The expected direction was declared before the run: the seven prerelease-floor pins go red because the range becomes unreadable, and the other 23 stay green. Observed: **7 failed | 23 passed (30)**. They are exactly the seven `reads a prerelease floor` cases, each failing on the helper's named refusal (`satisfiesCaret reads ^MAJOR.MINOR.PATCH[-PRERELEASE] with MAJOR >= 1, got "^18.0.0-rc.1"`). The plain-floor controls stayed green. Restore proven: blob `de6880de6d49` equals HEAD and `git diff HEAD` is empty. There is no `dist/` leg, because vitest reads this test file from source and the helper lives in it. ## Verification - `pnpm --filter create-objectstack exec vitest run --maxWorkers=2 src/runtime-image.test.ts`: **30 passed (30)** (17 before this change). - `pnpm --filter create-objectstack exec vitest run --maxWorkers=2` (whole package): **15 passed | 1 skipped (16) files, 233 passed | 14 skipped (247) tests**. - `pnpm --filter create-objectstack typecheck`: exit 0. `tsc --listFiles` includes `src/runtime-image.test.ts` (16 test files compiled). - Dependency closure first: `pnpm --filter 'create-objectstack^...' build` (builds `@objectstack/spec`), exit 0. - `pnpm lint`: exit 0 in 32 s. `eslint --format json` on the edited file: 1 result, 0 errors, 0 warnings (the file is linted, not ignored). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 52 commands. All 52 were run and reconciled with `--ran`: **52 derived, 50 run, 2 NOT MEASURED, 0 UNRUN**. - NOT MEASURED: `check:dual-build-cjs-loads` (86 packages without `dist/`) and `check:type-check-debt` (30 dependencies without built types). Both exited 3, PREREQUISITE NOT MET. A whole-workspace build would have 20 turbo cache misses (console and examples among them) on a shared host. - Why this diff cannot move either one: the diff changes no built output (dist grep below). `create-objectstack` has no `require` condition, and it is in neither the `DEBT` nor the `TEST_DEBT` ledger. CI's Lint & Repo Gates runs both after building the closure. - `check:lean-entry-closure` first exited 3. After a `turbo run build --filter=@objectstack/objectql` (a full cache hit) it re-ran green. - Roster rows that could apply: `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity` (the three rosters flagged under `packages/`), `check:scaffold-emission-policy`, `check:docs-image-tag`, spec `check:error-code-provenance` and `check-changeset-fixed`. All exit 0. `check-single-claim-paths`, `check-closing-target-claim` and `check-partof-closing-keyword` need PR context and were run once this PR existed; readings are in the report on the card. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0. `origin/main` is still `f11b5f20a`, an ancestor of HEAD (exit 0), so no merge was needed. - **Nothing ships (skip-changeset evidence).** `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After `pnpm --filter create-objectstack build`, a grep of those paths finds `satisfiesCaret`, `comparePrerelease` and `reads a prerelease floor` in 0 files. The positive controls `pinRuntimeImage` and `readResolvedCliVersion` are each found in 1 file. The seat applies the label. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. NODE_OPTIONS=--max-old-space-size=4096 pnpm --workspace-concurrency=2 --filter 'create-objectstack^...' build NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter create-objectstack exec vitest run --maxWorkers=2 src/runtime-image.test.ts NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter create-objectstack exec vitest run --maxWorkers=2 --reporter=verbose src/runtime-image.test.ts NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter create-objectstack typecheck NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter create-objectstack exec vitest run --maxWorkers=2 bash ablation-h3.sh (the H3 ablation above, wrapped whole) bash rc-e2e.sh (the RC end-to-end proof above, wrapped whole) NODE_OPTIONS=--max-old-space-size=4096 pnpm exec turbo run build --filter=@objectstack/objectql --concurrency=2 NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter create-objectstack build NODE_OPTIONS=--max-old-space-size=4096 pnpm lint (The helper printed the wording above once per run, each time naming that run's command. It is pasted once here, with every unlocked command in the block.) ## Acceptance notes - **One answer changes on a plain floor, and it is unreachable from the fixtures.** `satisfiesCaret('^17.4.0', '17.5.0-rc.1')` was `true` and is now `false`. That is semver's answer: a prerelease version is admitted only on a prerelease floor's own `X.Y.Z`. The suite's version argument is always a release (`OWN_MAJOR.(OWN_MINOR+1).2`, or the tag pinned from it), so no existing assertion read the old answer. It is pinned as `admits a prerelease version only on a prerelease floor`. - **The refusal is new behaviour.** An unreadable range (a `~` range, a `0.x` caret, or any shape outside `^MAJOR.MINOR.PATCH[-PRERELEASE]`) used to return `false` and now throws, naming the range. An unparseable VERSION still answers `false`, because a `latest` tag really does disagree with a range. - **The fixture derivation needed no change for RC.** `String('18.0.0-rc.1').split('.').map(Number)` gives `ownMinor` 0, so the planted version is `18.1.2`. That lies inside `^18.0.0-rc.1` and above its floor, as the RC end-to-end run shows. - The oracle matrix, the H1 probe and the two mutation scripts were one-time proofs run from the session scratchpad. No permanent test depends on `semver`. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent c876a74 commit e666636

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)