Skip to content

Commit 68fb6ac

Browse files
test(spec): the retired-after census reads a prerelease package label on its release triple (#23041)
Fixes #23025 Clause-②: no ## The rule The retired-after census (`packages/spec/src/conversions/retired-after.census.test.ts`) now reads `@objectstack/spec`'s package label **on its release triple**: - a stable label `x.y.z` is its own release triple; - a prerelease label `x.y.z-PRERELEASE` drops the suffix, so `18.0.0-next.N` compares as `18.0.0`; - a label that is neither still throws, naming the label. Every comparison against the label uses that triple: the census is never ahead of it, every `retiredAfter` is at or below it, and the release-pending range for unpublished stamps ends at it. Only the label gets this reading. A census version and a `retiredAfter` stamp stay strict stable `x.y.z` (`triple()` is unchanged), so a prerelease written into the census or onto an entry still throws. Why the release triple: it is how the artifact door already reads the runtime label (`parseVersion` in `packages/metadata-core/src/artifact-forward-conversion.ts` compares `x.y.z` triples and tolerates a suffix; PR #23023, now on `main`, keeps that reader). On a stable label the triple is the label, so no assertion changes meaning there. Pins, in the same file: - `the package label is read on its release triple` (new block, 4 cases): a stable label is its own triple; `18.0.0-next.0`, `18.0.0-next.12` and `18.1.0-rc.1` read as their release triples and compare at, never above, the release; nine malformed labels still throw, each naming the label; `triple()` and `cmp()` still throw on a prerelease, so the reading stays the label's alone. - The census suite itself, run under the label `18.0.0-next.0` through `scripts/ablation-replace.mjs` (below). ## What this PR does not settle (part 2 of triage's direction) The legal `retiredAfter` for a retirement landing under `18.0.0-next.N` was routed to #23001's window rule. PR #23023 has landed and #23001 is closed, but neither states a stamp rule for a retirement under a prerelease label. Its dev's report says the door's new major-step half "does not read retiredAfter for step-18 entries". Its module docblock describes the prerelease while the label "still names the last release of M-1". This PR takes no position on the question. What the census admits today, read off this diff: under a prerelease label the release-pending range is `[last censused release, release triple]`, today `[17.4.0, 18.0.0]`. So it admits both candidate stamps, the last 17.x release and `18.0.0`. Two facts for whoever decides it: - The PUBLISHED rule re-judges every stamp once the stable release is censused. A retirement that lands under `18.0.0-next.N` ships retired in `18.0.0`, so the census will then require the stable release before `18.0.0`. A stamp of `18.0.0` would pass now and go red at that refresh. - `.claude/skills/spec-property-retirement/SKILL.md` and the `retiredAfter` type doc (`packages/spec/src/conversions/types.ts`) tell an author to stamp the current package label. Under pre mode that label is `18.0.0-next.N`, which tsc's `${number}.${number}.${number}` type and this census both refuse, so the author has to guess. If the rule becomes "below the release triple under a prerelease label", the census change is one comparison in this file. #23001 is not addressed here. ## Reproduction, at base `3071d3e98` ```text node scripts/ablation-replace.mjs --file packages/spec/package.json \ --anchor '"version": "17.7.0"' --replacement '"version": "18.0.0-next.0"' \ -- pnpm --filter @objectstack/spec exec vitest run --project local src/conversions/retired-after.census.test.ts ``` - Mutation landed: anchor 1 -> 0, blob `9eba400e` -> `73244d5e`. - **Tests 3 failed | 2 passed (5).** Each failure is `Error: not a stable x.y.z version: '18.0.0-next.0'`, thrown by `triple()` in the three cases that compare against the label. - Restore: blob `9eba400e` == HEAD, `git diff HEAD` empty. ## Sweep: every read of the package label in `packages/spec` tests and scripts `git grep` over `packages/spec/src/**/*.test.ts`, `packages/spec/scripts/**` and the vitest configs, for `package.json` reads, `.version` reads, `version.split('.')`, `parseInt` of a version, strict `x.y.z` regex literals and `semver` imports (none). Each hit, and what it does under `18.0.0-next.0`: | Site | How it reads the label | Under `18.0.0-next.0` | Verdict | |---|---|---|---| | `src/conversions/retired-after.census.test.ts` | strict `x.y.z` regex in `triple()` | throws, 3 cases | **fixed here** | | `src/kernel/protocol-version.test.ts:24`-`:42` | `split('.')[0]`; `includes('-')` | major 18 equals `PROTOCOL_MAJOR` 18, so the equality branch passes | prerelease-aware; measured green below | | `scripts/build-schemas-check-mode.test.ts:996` (`repo` project) | `Number.parseInt(label, 10)` | 18, identical to a `18.0.0` label | never sees the suffix; see the not-measured note | | `scripts/openapi-self-consistency.test.ts:303` | string equality with the generated `info.version` | both sides read the same label | no parse; measured green below | | `scripts/lib/upgrade-guide-docs.ts:55` (`releaseStatus`, from `build-upgrade-guide.ts:194`) | `MAJOR.MINOR.PATCH[-PRE]` regex | status `prerelease` | already prerelease-aware | | `scripts/build-schemas.ts:300`, `:767` | interpolated into URLs and `x-spec-version`; `parseInt` major | no throw | no strict parse | | `scripts/build-openapi.ts:30` | written into `info.version` | no throw | no parse | | `scripts/build-spec-changes.ts:101` | passed through as `toVersion` | no throw | no parse | | `scripts/analyze-bundle-size.ts:176` | a report field | no throw | no parse | | `scripts/build-retired-after-census.ts:92` | `parseStable` over packument versions, not the label | n/a | stable-only by design: the census holds stable releases | No shipped parser. Every hit is a test or a script, and `packages/spec`'s `files[]` ships neither (measured below), so the change stays tests-only. The measured half: the whole spec `local` project under `18.0.0-next.0`, on the fixed tree, is green (below). Since this diff touches only the census file, that run is also every other `local` test's base reading: the census was the only red. The spec files `main` brought in since add no label read (re-swept after the merge). ## Verification, at head `ee6874ff9` (the fix with `origin/main` `0ede0f8ab` merged in) - **Census file, real label `17.7.0`:** `pnpm --filter @objectstack/spec exec vitest run --project local src/conversions/retired-after.census.test.ts` gives Test Files 1 passed, Tests 9 passed (9). - **Census file, label `18.0.0-next.0`**, the same command wrapped by `scripts/ablation-replace.mjs` as in the reproduction: the mutation landed (anchor 1 -> 0, blob `9eba400e` -> `73244d5e`), and Tests 9 passed (9). Restore: blob `9eba400e` == HEAD, `git diff HEAD` empty. With the reproduction this is the reverse verification: the same command at base gives 3 failed | 2 passed. - Neither run is vacuous on the UNPUBLISHED rule. The census trails at 17.4.0, so both labels take the release-pending branch over the 44 unpublished stamps: `[17.4.0, 17.7.0]` on the real label, `[17.4.0, 18.0.0]` on the prerelease label. - **The whole spec `local` project under `18.0.0-next.0`**, through the same tool, at `7c75f2c49` (the fix before the merge): Test Files 650 passed | 1 skipped (651), Tests 19470 passed | 1 skipped | 1 todo. Restored, blob == HEAD. `vitest list --project local` confirms `src/kernel/protocol-version.test.ts` and `scripts/openapi-self-consistency.test.ts` are in that project. - `pnpm --filter @objectstack/spec build`: exit 0. `pnpm --filter @objectstack/spec typecheck`: exit 0. Its `check:test-typecheck` compiles `tsconfig.test.json`. That program lists the census test (`tsc --listFilesOnly`: 2368 files), and `test-typecheck-debt.json` has no row for it. - Declared narrowing: the whole `local` project on the real label at the merged head is left to CI. The diff against `main` is this one file, run on both labels at that head above. - **Publish surface (`skip-changeset`):** `npm pack --dry-run` lists 2080 files, none of them `*.test.ts`; positive control `src/kernel/manifest.zod.ts` is listed. `releaseTriple` has 0 hits in `dist/`; positive control `retiredAfter` is found in `dist/shared/index.js`. - **Narrowed lint**, a measurement rather than CI's full `pnpm lint`: `eslint --no-inline-config --format json` over the one touched file reports 1 file, 0 errors, 0 warnings. The file is inside the config's population (`--print-config` resolves 4 rules for it, and no ignored notice is reported). The config enables no type-aware linting (no `parserOptions.project` or `projectService`), so the diff cannot move a verdict on an untouched file. ## Gates, at head `ee6874ff9` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 75. 74 were run, each exit captured before any pipe, and 72 exited 0. `--ran` reconciliation: 75 derived, 72 run, 2 NOT-MEASURED, 1 UNRUN. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:lean-entry-closure` exited 3 (PREREQUISITE NOT MET: no built output for 83 packages, and for objectql). This diff changes no build input. - UNRUN by instruction: `check:type-check-debt`, which the dispatch marks NOT MEASURED. - `check:doc-formula-expressions` first exited 3 (`@objectstack/formula` unbuilt). After `@objectstack/formula` and `@objectstack/lint` were built, it exited 0. ## Acceptance notes - **Part 2 has no open carrier.** See the section above. Routing it is the seat's call. - `scripts/build-schemas-check-mode.test.ts` (the `repo` project's only label reader) is NOT MEASURED under the prerelease label. One attempt was cut by a foreground timeout mid-run, and a second never got the verify lock (exit 99). Its read is `parseInt`, which gives 18 for `18.0.0-next.0` and for `18.0.0` alike, so any red there is the 17 to 18 major move, not the prerelease suffix. - The `!pending` branch of the UNPUBLISHED rule (exact label) is not exercised by any current tree: the census trails at 17.4.0, so both labels take the pending branch. Under the release triple it requires the stamp to equal the triple, which is unreachable while the label is a prerelease (the census never records a prerelease tarball). --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1959aac commit 68fb6ac

1 file changed

Lines changed: 67 additions & 7 deletions

File tree

‎packages/spec/src/conversions/retired-after.census.test.ts‎

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ import { ALL_CONVERSIONS } from './registry.js';
3232
* version from the census's last release up to the label — it landed either
3333
* before the bump or after it, and only the new tarball can say which.
3434
* Refreshing the census after the publish closes the tolerance again.
35+
*
36+
* The label is read on its RELEASE TRIPLE. Changesets pre mode writes a
37+
* prerelease label (`18.0.0-next.0`) into this package on the Version Packages
38+
* PR, and onto `main` once that lands, so every comparison against the label
39+
* uses `x.y.z` with the prerelease suffix dropped: `18.0.0-next.N` reads as
40+
* `18.0.0`. That is the reading the artifact door gives the runtime label (it
41+
* compares `x.y.z` triples and tolerates a suffix), and on a stable label it is
42+
* the label itself, so no assertion changes meaning there. Under a prerelease
43+
* label the census never records that label's tarball (it holds stable
44+
* releases only), so the release-pending tolerance above stays open, spanning
45+
* `[last censused release, release triple]`, until the stable release is
46+
* published and censused, when the PUBLISHED rule re-judges every stamp. Which
47+
* stamp a retirement landing under a prerelease label should carry is the
48+
* artifact door's window question, and this file follows that rule rather
49+
* than settling it. Only the label gets this reading: a census version and a
50+
* `retiredAfter` stamp stay stable `x.y.z`, and a label that is neither
51+
* `x.y.z` nor `x.y.z-PRERELEASE` still throws.
3552
*/
3653

3754
type Triple = [number, number, number];
@@ -69,6 +86,22 @@ function cmp(a: string, b: string): number {
6986
return 0;
7087
}
7188

89+
/**
90+
* The release triple of a package label: `x.y.z` as is, `x.y.z-PRERELEASE`
91+
* with the suffix dropped (`18.0.0-next.0` reads as `18.0.0`). Anything else
92+
* throws. See the file docblock for why the label alone is read this way.
93+
*/
94+
function releaseTriple(label: string): string {
95+
const m = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(label);
96+
if (!m) throw new Error(`not an x.y.z or x.y.z-prerelease package label: '${label}'`);
97+
return m[1]!;
98+
}
99+
100+
/** The label as every comparison below reads it. */
101+
const RELEASE = releaseTriple(LABEL);
102+
/** The label as a failure message names it. */
103+
const LABEL_SHOWN = LABEL === RELEASE ? `'${LABEL}'` : `'${LABEL}' (release triple '${RELEASE}')`;
104+
72105
const LAST_CENSUSED = CENSUS.releases.at(-1)!.version;
73106

74107
/**
@@ -91,7 +124,7 @@ describe('retiredAfter census — every retired entry stamped from the published
91124
for (let i = 1; i < versions.length; i++) {
92125
expect(cmp(versions[i - 1]!, versions[i]!), `${versions[i - 1]} < ${versions[i]}`).toBe(-1);
93126
}
94-
expect(cmp(LAST_CENSUSED, LABEL), `census (${LAST_CENSUSED}) is ahead of the label (${LABEL})`).toBeLessThanOrEqual(0);
127+
expect(cmp(LAST_CENSUSED, RELEASE), `census (${LAST_CENSUSED}) is ahead of the label ${LABEL_SHOWN}`).toBeLessThanOrEqual(0);
95128
for (const r of CENSUS.releases) {
96129
expect(r.integrity).toMatch(/^sha512-/);
97130
expect([...r.retired].sort()).toEqual(r.retired);
@@ -102,7 +135,7 @@ describe('retiredAfter census — every retired entry stamped from the published
102135
expect(RETIRED.length).toBeGreaterThan(0);
103136
for (const c of RETIRED) {
104137
expect(c.retiredAfter, c.id).toMatch(/^\d+\.\d+\.\d+$/);
105-
expect(cmp(c.retiredAfter!, LABEL), `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL}`).toBeLessThanOrEqual(0);
138+
expect(cmp(c.retiredAfter!, RELEASE), `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL_SHOWN}`).toBeLessThanOrEqual(0);
106139
}
107140
});
108141

@@ -126,17 +159,44 @@ describe('retiredAfter census — every retired entry stamped from the published
126159
});
127160

128161
it('every UNPUBLISHED entry carries the package label (the release-pending range while the census trails it)', () => {
129-
const pending = cmp(LABEL, LAST_CENSUSED) > 0;
162+
const pending = cmp(RELEASE, LAST_CENSUSED) > 0;
130163
const wrong: string[] = [];
131164
for (const c of RETIRED) {
132165
if (publishedRetiredAfter(c.id) !== null) continue;
133166
const v = c.retiredAfter!;
134-
if (!pending && v !== LABEL) {
135-
wrong.push(`${c.id}: retiredAfter '${v}', but no published tarball retires it, so it takes the label '${LABEL}'`);
136-
} else if (pending && (cmp(v, LAST_CENSUSED) < 0 || cmp(v, LABEL) > 0)) {
137-
wrong.push(`${c.id}: retiredAfter '${v}' is outside [${LAST_CENSUSED}, ${LABEL}] — refresh the census (${REGEN})`);
167+
if (!pending && v !== RELEASE) {
168+
wrong.push(`${c.id}: retiredAfter '${v}', but no published tarball retires it, so it takes the label ${LABEL_SHOWN}`);
169+
} else if (pending && (cmp(v, LAST_CENSUSED) < 0 || cmp(v, RELEASE) > 0)) {
170+
wrong.push(`${c.id}: retiredAfter '${v}' is outside [${LAST_CENSUSED}, ${RELEASE}] — refresh the census (${REGEN})`);
138171
}
139172
}
140173
expect(wrong).toEqual([]);
141174
});
142175
});
176+
177+
describe('the package label is read on its release triple', () => {
178+
it('a stable label is its own release triple', () => {
179+
expect(releaseTriple('17.7.0')).toBe('17.7.0');
180+
expect(releaseTriple('18.0.0')).toBe('18.0.0');
181+
});
182+
183+
it('a prerelease label reads as its release triple', () => {
184+
expect(releaseTriple('18.0.0-next.0')).toBe('18.0.0');
185+
expect(releaseTriple('18.0.0-next.12')).toBe('18.0.0');
186+
expect(releaseTriple('18.1.0-rc.1')).toBe('18.1.0');
187+
// ...and so compares at, never above, the release it precedes.
188+
expect(cmp('17.7.0', releaseTriple('18.0.0-next.0'))).toBe(-1);
189+
expect(cmp('18.0.0', releaseTriple('18.0.0-next.0'))).toBe(0);
190+
});
191+
192+
it('a malformed label still throws, naming the label', () => {
193+
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']) {
194+
expect(() => releaseTriple(bad), JSON.stringify(bad)).toThrow(`'${bad}'`);
195+
}
196+
});
197+
198+
it('the release-triple reading is the label\'s alone: a census version or a stamp stays stable x.y.z', () => {
199+
expect(() => triple('18.0.0-next.0')).toThrow(`'18.0.0-next.0'`);
200+
expect(() => cmp('18.0.0-next.0', '17.7.0')).toThrow(`'18.0.0-next.0'`);
201+
});
202+
});

0 commit comments

Comments
 (0)