From 89cb6b0d14a6cb5b89e90c503dcf47012847a9f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 11:30:53 +0000 Subject: [PATCH] fix(release): carry QUICK_REFERENCE's Current Release block in changeset:version (#5394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Current Release" block fossilised once per release, three times (#4642, #4977, #5394). Its own preamble assumed a human holds the anchor when it moves — but the anchor moves inside `changeset version`, run by `changeset-release.yml` with nobody present, and the release PR is not a backstop: every `ci.yml` run on `changeset-release/main` is `conclusion: action_required` with `created_at == run_started_at`, because a GITHUB_TOKEN-authored PR starts no workflows. The first thing that reads a release is the push build on `main`, which is why `main` — never a PR — went red every time. So the derivation has to RUN where the anchor moves. `changeset:version` now runs `scripts/sync-quick-reference-release.mjs`, which rewrites only the version literals of the anchored rows, preserving each row's hand-written anchor note. It refuses (non-zero) rather than guessing when a row's literal count no longer matches its anchor, and leaves the unanchored TypeScript row alone. `quick-reference-current-release-4143.test.ts` is untouched — it stays the judge. The new test pins the wiring in both places (`package.json` and the workflow's `version:` input) and pins that the writer and the judge spell `VERSION_LITERAL` identically. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- .../quick-reference-release-sync-5394.md | 16 + QUICK_REFERENCE.md | 8 +- package.json | 4 +- .../sync-quick-reference-release.test.ts | 227 ++++++++++ scripts/sync-quick-reference-release.mjs | 410 ++++++++++++++++++ 5 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 .changeset/quick-reference-release-sync-5394.md create mode 100644 scripts/__tests__/sync-quick-reference-release.test.ts create mode 100644 scripts/sync-quick-reference-release.mjs diff --git a/.changeset/quick-reference-release-sync-5394.md b/.changeset/quick-reference-release-sync-5394.md new file mode 100644 index 0000000000..d88e63d9ed --- /dev/null +++ b/.changeset/quick-reference-release-sync-5394.md @@ -0,0 +1,16 @@ +--- +--- + +Release tooling and repo docs only — this publishes nothing, declared explicitly with an +empty frontmatter rather than left undeclared. + +`changeset:version` now runs `scripts/sync-quick-reference-release.mjs` after bumping the +manifests, so `QUICK_REFERENCE.md`'s "Current Release" block moves in the same commit as +the versions it quotes. Before this, the release path had no human in it and nothing +updated the doc, so the block fossilised once per release and the anti-fossil gate +`scripts/__tests__/quick-reference-current-release-4143.test.ts` reddened `main` on the +push build every time (objectui#4642, objectui#4977, objectui#5394). The gate is +unchanged — it is the judge; this is the thing that keeps the doc true. + +No package `src/` is touched, so no `@object-ui/*` package changes behaviour and there is +nothing here for a consumer to upgrade to. diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index 491fb4bebe..5ad4aa4ce7 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -111,7 +111,13 @@ Every value below is pinned to the manifest that owns it by `scripts/__tests__/quick-reference-current-release-4143.test.ts` — edit the anchor and that test tells you to edit this block. The one exception is called out on its row. -- **Version:** 17.5.0 (the version every `@object-ui/*` manifest carries — they are one +You rarely have to edit it by hand. `pnpm quick-reference:sync` rewrites these rows from +the manifests, and `pnpm quick-reference:check` reports drift without writing. The +release path runs the sync itself: `changeset:version` bumps every manifest and updates +this block in the same commit, so a release can no longer leave the block a version +behind (objectui#5394 — that had happened once per release, three times). + +- **Version:** 17.6.0 (the version every `@object-ui/*` manifest carries — they are one `fixed` group in `.changeset/config.json`, so a release moves all of them together) - **Spec:** `@objectstack/spec` ^17.0.0 (declared by the root `package.json` and by `apps/console/package.json`) diff --git a/package.json b/package.json index 506e7c8a0d..806a835c98 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,9 @@ "shadcn:list": "node scripts/shadcn-sync.js --list", "changeset:check": "node scripts/check-changeset-fixed.mjs && node scripts/check-changeset-no-major.mjs", "changeset": "changeset", - "changeset:version": "changeset version", + "changeset:version": "changeset version && node scripts/sync-quick-reference-release.mjs", + "quick-reference:sync": "node scripts/sync-quick-reference-release.mjs", + "quick-reference:check": "node scripts/sync-quick-reference-release.mjs --check", "changeset:publish": "node scripts/check-published-dist-tooling.mjs && changeset publish", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/scripts/__tests__/sync-quick-reference-release.test.ts b/scripts/__tests__/sync-quick-reference-release.test.ts new file mode 100644 index 0000000000..082e389206 --- /dev/null +++ b/scripts/__tests__/sync-quick-reference-release.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS release helper. Its types are INFERRED from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`), the same arrangement +// `check-changeset-no-major.test.ts` documents — so no `@ts-expect-error` here. +import { + assignSlots, + derivedLiteralsByRow, + readAnchors, + syncDoc, + VERSION_LITERAL, +} from '../sync-quick-reference-release.mjs'; + +/** + * objectui#5394 — `QUICK_REFERENCE.md`'s "Current Release" block fossilised once per + * release, three times (objectui#4642, objectui#4977, objectui#5394). The gate that + * catches it (`quick-reference-current-release-4143.test.ts`) is correct and untouched; + * what was missing is anything that MOVES the doc when the anchor moves. + * + * The anchor moves inside `changeset version`, run by `changeset-release.yml` with no + * human present, and the release PR is not a backstop: every `ci.yml` run on branch + * `changeset-release/main` is `conclusion: "action_required"` with + * `created_at == run_started_at`, because a `GITHUB_TOKEN`-authored PR does not start + * workflows. The first thing that ever reads the release is the `push` build on `main`, + * which is why `main` — not a PR — went red every time. + * + * So the fix is a script wired INTO `changeset:version`, and the load-bearing assertion + * in this file is the wiring one. A sync script nothing calls is exactly as effective as + * the hand edit it replaces: the first two instances were both fixed by someone who knew + * the right value and typed it in. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (rel: string): string => fs.readFileSync(path.join(repoRoot, rel), 'utf8'); + +const SCRIPT_REL = 'scripts/sync-quick-reference-release.mjs'; +const GATE_REL = 'scripts/__tests__/quick-reference-current-release-4143.test.ts'; +const DOC_REL = 'QUICK_REFERENCE.md'; + +describe('the release path actually runs the sync', () => { + /** + * The whole card in one assertion. `changeset version` is the only moment the + * workspace version moves, and nobody is watching it — if this script is not on + * that command line, the doc goes stale again on the next release and `main` goes + * red again, exactly as it did three times. + */ + it('`changeset:version` invokes the sync script after bumping the manifests', () => { + const pkg = JSON.parse(read('package.json')) as { scripts?: Record }; + const script = pkg.scripts?.['changeset:version']; + + expect(script, 'root package.json must still declare a `changeset:version` script').toBeDefined(); + expect( + script, + '`changeset:version` must still run `changeset version` — it is what the release ' + + 'workflow calls to bump the manifests', + ).toContain('changeset version'); + expect( + script, + `\`changeset:version\` must also run ${SCRIPT_REL}, or the release bumps every manifest ` + + `and leaves ${DOC_REL} a version behind (objectui#5394, and objectui#4642 before it)`, + ).toContain(SCRIPT_REL); + }); + + /** + * The other half of the wiring: the workflow must go THROUGH the npm script rather + * than calling `changeset version` itself. Pinning only `package.json` would leave a + * one-word edit here able to skip the sync silently. + */ + it('`changeset-release.yml` versions through `pnpm changeset:version`', () => { + const workflow = read('.github/workflows/changeset-release.yml'); + const versionInput = workflow.match(/^\s*version:\s*(.+)$/m)?.[1]?.trim(); + + expect( + versionInput, + 'changeset-release.yml must still hand the changesets action a `version:` command', + ).toBeDefined(); + expect( + versionInput, + 'the release must version through the `changeset:version` npm script — calling ' + + '`changeset version` directly here would bypass the sync that keeps ' + + `${DOC_REL} in step with the manifests`, + ).toBe('pnpm changeset:version'); + }); +}); + +describe('the writer and the judge read the same tokens', () => { + /** + * The script writes literals; the gate judges them. Two tokenizers would be free to + * drift, and the failure mode is a doc that this script calls synced and the gate + * calls stale — a release that cannot be made green by running the tool that is + * supposed to make it green. Compared as SOURCE TEXT so the two spellings cannot + * diverge even in ways that happen to behave alike today. + */ + it('the script and the gate spell `VERSION_LITERAL` identically', () => { + const extract = (source: string): string | undefined => + source.match(/VERSION_LITERAL = (\/.*\/g);/)?.[1]; + + const fromScript = extract(read(SCRIPT_REL)); + const fromGate = extract(read(GATE_REL)); + + expect(fromScript, `${SCRIPT_REL} must still define VERSION_LITERAL as a regex literal`).toBeDefined(); + expect(fromGate, `${GATE_REL} must still define VERSION_LITERAL as a regex literal`).toBeDefined(); + expect( + fromScript, + 'the sync script and the gate must tokenize version literals identically, or the ' + + 'script can "fix" a row the gate still reports', + ).toBe(fromGate); + expect(String(VERSION_LITERAL)).toBe(fromScript); + }); +}); + +describe('the block is in step with its anchors right now', () => { + /** + * The same fact the gate asserts, from the writing side: running the sync against the + * committed tree must be a no-op. A green gate plus a sync that still wants to edit + * the file would mean the two disagree about what the anchors say. + */ + it('syncing the committed tree changes nothing and refuses nothing', () => { + const result = syncDoc(read(DOC_REL), derivedLiteralsByRow(readAnchors(repoRoot))); + + expect(result.refusals, 'the sync must be able to read every anchored row').toEqual([]); + expect( + result.changes, + `${DOC_REL} has drifted from its anchors — run \`pnpm quick-reference:sync\``, + ).toEqual([]); + expect(result.doc).toBe(read(DOC_REL)); + }); +}); + +describe('a release bump carries the doc with it', () => { + const anchors = readAnchors(repoRoot); + const doc = read(DOC_REL); + + /** + * The recurrence, simulated at the unit level: move the one anchor `changeset + * version` moves, and the row must follow without anyone touching the file. + */ + it('rewrites the Version row when the fixed group moves', () => { + const bumped = { ...anchors, version: '99.9.9' }; + const result = syncDoc(doc, derivedLiteralsByRow(bumped)); + + expect(result.refusals).toEqual([]); + expect(result.changes).toEqual([{ label: 'Version', from: anchors.version, to: '99.9.9' }]); + expect(result.doc).toContain(`- **Version:** 99.9.9 (the version every`); + expect(result.doc).not.toContain(anchors.version); + }); + + /** + * The prose is the reason these rows are readable — each one names its own anchor in + * hand-written text, and the gate's header calls that out as deliberately rewordable. + * A sync that reflowed or regenerated the block would take that away and would make + * the gate assert generated text against the manifests it was generated from. Only + * the literal moves. + */ + it('touches nothing but the literal', () => { + const { doc: after } = syncDoc(doc, derivedLiteralsByRow({ ...anchors, version: '99.9.9' })); + + const before = doc.split('\n'); + const lines = after.split('\n'); + expect(lines.length).toBe(before.length); + + const differing = lines.map((line, i) => [i, line, before[i]] as const).filter(([, a, b]) => a !== b); + expect(differing).toHaveLength(1); + expect(differing[0][1]).toBe(differing[0][2].replace(anchors.version, '99.9.9')); + }); + + /** + * Two literals on one row, one of them moving: the pnpm row bumps its + * `packageManager` pin while `engines.pnpm` holds. Identity-first assignment is what + * keeps the floor in the floor's slot; naive positional filling would write the + * pinned version into it. + */ + it('moves only the pnpm literal that changed', () => { + const { doc: after, changes, refusals } = syncDoc( + doc, + derivedLiteralsByRow({ ...anchors, pnpmPinned: '11.0.0' }), + ); + + expect(refusals).toEqual([]); + expect(changes).toEqual([{ label: 'pnpm', from: anchors.pnpmPinned, to: '11.0.0' }]); + expect(after).toContain(`≥ ${anchors.pnpmFloor} (the workspace pins \`pnpm@11.0.0\``); + }); + + /** + * A row whose literal count no longer matches its anchor's. Which slot gets which + * value is a guess at that point, and a doc that is confidently wrong is worse than + * one that is visibly stale — so the script refuses the row, exits non-zero, and the + * release stops instead of committing the guess. + */ + it('refuses a row whose shape no longer matches its anchor', () => { + const grown = { ...anchors, reactMajors: [...anchors.reactMajors, '20'] }; + const { doc: after, changes, refusals } = syncDoc(doc, derivedLiteralsByRow(grown)); + + expect(changes).toEqual([]); + expect(after).toBe(doc); + expect(refusals).toHaveLength(1); + expect(refusals[0].label).toBe('React'); + expect(refusals[0].reason).toMatch(/would be a guess/); + }); + + /** + * The unanchored TypeScript row derives from nothing, so the sync must leave it + * alone rather than invent a value for it. The gate allowlists it by exact text and + * fails the day an anchor for it appears; that is where the row's fate is decided. + */ + it('leaves the unanchored TypeScript row alone', () => { + expect([...derivedLiteralsByRow(anchors).keys()]).not.toContain('TypeScript'); + const { doc: after } = syncDoc(doc, derivedLiteralsByRow({ ...anchors, version: '99.9.9' })); + expect(after).toContain('- **TypeScript:** ≥ 5.0 (strict mode)'); + }); +}); + +describe('assignSlots', () => { + it('keeps the literals that are already right where they are', () => { + expect(assignSlots(['≥9', '10.31.0'], ['≥ 9', '11.0.0'])).toEqual([null, '11.0.0']); + }); + + it('fills leftover slots in row order when everything moved', () => { + expect(assignSlots(['≥9', '10.31.0'], ['≥ 10', '11.0.0'])).toEqual(['≥ 10', '11.0.0']); + }); + + it('is a no-op when the row already states its anchor', () => { + expect(assignSlots(['17.6.0'], ['17.6.0'])).toEqual([null]); + }); +}); diff --git a/scripts/sync-quick-reference-release.mjs b/scripts/sync-quick-reference-release.mjs new file mode 100644 index 0000000000..9b187fe560 --- /dev/null +++ b/scripts/sync-quick-reference-release.mjs @@ -0,0 +1,410 @@ +#!/usr/bin/env node +/** + * Rewrites `QUICK_REFERENCE.md`'s "Current Release" rows from the manifests that + * anchor them, so the block cannot fossilise when an anchor moves. + * + * ## Why this exists (objectui#5394 — the third instance of one mechanism) + * + * `scripts/__tests__/quick-reference-current-release-4143.test.ts` derives the + * legal version literals from the manifests and fails on any literal in the block + * that nothing derives. It is correct and stays exactly as it is; this script is + * not a way to make it quieter, it is the thing that keeps the doc true so the + * gate has nothing to report. + * + * The gate's own preamble states the intended workflow — "edit the anchor and that + * test tells you to edit this block". That assumes a human is holding the anchor + * when it moves. **At release time nobody is.** `.github/workflows/changeset-release.yml` + * runs `pnpm changeset:version`, which rewrites every manifest in the `fixed` group, + * and the changesets action commits the result to `changeset-release/main`. Nothing + * in that path had ever touched this doc, so the block was guaranteed to fossilise + * once per release: + * + * - objectui#4642 — doc said 17.4.0 after the 17.5.0 release commit + * - objectui#4977 — the GA-pin residue of the same family + * - objectui#5394 — doc said 17.5.0 after the 17.6.0 release commit + * + * And the release PR is not a backstop, which was measured rather than assumed: + * every `ci.yml` run on branch `changeset-release/main` carries + * `conclusion: "action_required"` with `created_at == run_started_at` — the + * workflow never executes there, because the PR is authored by the bot with + * `GITHUB_TOKEN`. So the gate cannot see the release until it is already on `main`, + * which is why `main` (not a PR) goes red once per release. A fix that only makes + * the doc DERIVABLE changes nothing about that; the derivation has to RUN at the + * moment the anchor moves, and the only such moment in the release path is + * `changeset:version`. That is where `package.json` invokes this file, and + * `scripts/__tests__/sync-quick-reference-release.test.ts` pins the wiring so the + * next person cannot quietly unhook it. + * + * ## What it rewrites, and what it refuses to + * + * Only version LITERALS, only inside `## Current Release`, only on rows that carry + * an anchor. The hand-written prose around them — including each row's note naming + * its own anchor — is preserved verbatim, because those notes are the reason the + * rows are readable and the gate's header calls them out as deliberately rewordable. + * + * A row is rewritten only when its literal COUNT still matches its anchor's. If a + * row states three literals where the anchor derives two (the React row grows a + * major, say), positional substitution would be a guess, so this refuses that row + * and exits non-zero. Loud refusal fails the release step; the alternative — guess + * and commit — is how a doc ends up confidently wrong. The gate stays the judge + * either way. + * + * The `TypeScript` row is untouched on purpose: it has no anchor (no workspace + * manifest declares a `typescript` range), the gate allowlists it by exact text and + * fails the moment an anchor appears. Nothing here can derive it, so nothing here + * pretends to. + * + * Run: node scripts/sync-quick-reference-release.mjs # rewrite in place + * node scripts/sync-quick-reference-release.mjs --check # report only + * Exit: 0 = in sync (or synced), 1 = out of sync (--check) or a row was refused + */ + +import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** The document this script owns a few literals inside of. */ +export const DOC_REL = 'QUICK_REFERENCE.md'; + +/** + * A version literal, in the spellings this block uses. + * + * Deliberately character-for-character the same source text as `VERSION_LITERAL` + * in `scripts/__tests__/quick-reference-current-release-4143.test.ts`. Two copies + * would be free to drift — one tokenizer writing and a different one judging is + * how a "synced" doc still fails the gate — so the test file asserts the two + * spellings are identical rather than trusting this comment. + */ +export const VERSION_LITERAL = /(?:\^|~|>=|<=|>|<|≥|≤)\s*v?\d[\w.-]*|\bv?\d+\.[A-Za-z0-9][\w.-]*/g; + +/** Whitespace-insensitive comparison key, matching the gate's `normalize`. */ +export const normalize = (literal) => literal.replace(/\s+/g, ''); + +/** + * @typedef {object} Anchors + * @property {string} version the one version the `fixed` group carries + * @property {string} spec the `@objectstack/spec` range + * @property {string} client the `@objectstack/client` range + * @property {string} nodeFloor numeric floor out of root `engines.node` + * @property {string} pnpmFloor numeric floor out of root `engines.pnpm` + * @property {string} pnpmPinned the version half of root `packageManager` + * @property {string[]} reactMajors the majors named by the react peer range + */ + +function manifest(root, rel) { + return JSON.parse(readFileSync(join(root, rel), 'utf8')); +} + +/** + * The majors named by a peer range like `^18.0.0 || ^19.0.0`. + * + * Split on the `||` comparators first and take the leading number of each. One + * global `\d+\.` sweep instead returns `[18, 0, 19, 0]` — the patch segments match + * it too — and here that mistake is silent: an extra major only adds a literal the + * row does not state, and the count check would then refuse the row for the wrong + * reason. Same reasoning as the gate's copy of this function. + */ +export function peerMajors(range) { + return range + .split('||') + .map((comparator) => comparator.trim().match(/^[\^~>=<\s]*(\d+)\./)?.[1]) + .filter((major) => major !== undefined); +} + +/** + * Reads every anchor the block quotes, from the manifests that own them. + * + * Throws — rather than defaulting — when an anchor is missing or when two + * manifests a row names as its joint anchor disagree. A row that names two files + * cannot state one value while they disagree, and the release path must stop + * rather than pick one: the same judgement the gate makes, made before the write + * instead of after it. + * + * @param {string} [root] + * @returns {Anchors} + */ +export function readAnchors(root = repoRoot) { + const rootManifest = manifest(root, 'package.json'); + const consoleManifest = manifest(root, 'apps/console/package.json'); + const dataObjectstack = manifest(root, 'packages/data-objectstack/package.json'); + const reactManifest = manifest(root, 'packages/react/package.json'); + + const changesetConfig = JSON.parse(readFileSync(join(root, '.changeset/config.json'), 'utf8')); + const fixedGroup = changesetConfig.fixed?.[0] ?? []; + if (fixedGroup.length <= 1) { + throw new Error('.changeset/config.json must still declare one `fixed` group of packages'); + } + + // The version is a fact about the WORKSPACE, not about whichever manifest we + // happened to read: assert the group still carries one version before quoting + // it, exactly as the gate does. A split group makes a single `**Version:**` row + // inexpressible, and this must say so rather than pin an arbitrary member. + const versions = new Map(); + for (const rel of ['packages', 'apps']) { + for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const pkgRel = `${rel}/${entry.name}/package.json`; + if (!existsSync(join(root, pkgRel))) continue; + const pkg = manifest(root, pkgRel); + if (!pkg.name || !fixedGroup.includes(pkg.name)) continue; + versions.set(pkg.name, pkg.version ?? ''); + } + } + const distinct = [...new Set(versions.values())]; + if (distinct.length !== 1) { + throw new Error( + `the changeset \`fixed\` group must carry ONE version; got ${JSON.stringify([...versions])}`, + ); + } + + const specFromRoot = rootManifest.devDependencies?.['@objectstack/spec']; + const specFromConsole = consoleManifest.devDependencies?.['@objectstack/spec']; + if (!specFromRoot || !specFromConsole) throw new Error('@objectstack/spec is no longer declared where the Spec row says it is'); + if (specFromRoot !== specFromConsole) { + throw new Error( + `root and apps/console declare different @objectstack/spec ranges (${specFromRoot} vs ${specFromConsole}) — ` + + `${DOC_REL}'s Spec row names both as its anchor and can no longer state one value`, + ); + } + + const clientFromConsole = consoleManifest.devDependencies?.['@objectstack/client']; + const clientFromAdapter = dataObjectstack.dependencies?.['@objectstack/client']; + if (!clientFromConsole || !clientFromAdapter) throw new Error('@objectstack/client is no longer declared where the Client row says it is'); + if (clientFromConsole !== clientFromAdapter) { + throw new Error( + `apps/console and packages/data-objectstack declare different @objectstack/client ranges ` + + `(${clientFromConsole} vs ${clientFromAdapter}) — ${DOC_REL}'s Client row names both as its anchor`, + ); + } + + const nodeFloor = rootManifest.engines?.node?.match(/(\d+)/)?.[1]; + const pnpmFloor = rootManifest.engines?.pnpm?.match(/(\d+)/)?.[1]; + const pnpmPinned = rootManifest.packageManager?.split('@').pop(); + if (!nodeFloor) throw new Error('root package.json must still declare a numeric engines.node floor'); + if (!pnpmFloor) throw new Error('root package.json must still declare a numeric engines.pnpm floor'); + if (!pnpmPinned) throw new Error('root package.json must still declare packageManager'); + + const peer = reactManifest.peerDependencies?.react; + if (!peer) throw new Error('packages/react must still declare a react peer range'); + const reactMajors = peerMajors(peer); + if (reactMajors.length === 0) throw new Error(`could not read majors out of the react peer range "${peer}"`); + + return { + version: distinct[0], + spec: specFromRoot, + client: clientFromConsole, + nodeFloor, + pnpmFloor, + pnpmPinned, + reactMajors, + }; +} + +/** + * The literals each anchored row must state, **in the order the row states them**. + * + * Order matters here and nowhere else: when more than one literal on a row changes + * at once, `syncDoc` fills the leftover slots left-to-right, so this array is what + * decides which value lands in which slot. The pnpm row reads "floor, then pinned", + * and so does this. + * + * @param {Anchors} anchors + * @returns {Map} + */ +export function derivedLiteralsByRow(anchors) { + return new Map([ + ['Version', [anchors.version]], + ['Spec', [anchors.spec]], + ['Client', [anchors.client]], + ['Node.js', [`≥ ${anchors.nodeFloor}`]], + ['pnpm', [`≥ ${anchors.pnpmFloor}`, anchors.pnpmPinned]], + ['React', anchors.reactMajors.map((major) => `${major}.x`)], + ]); +} + +const SECTION_HEADING = '## Current Release'; + +/** + * Line spans of the anchored rows inside `## Current Release`. + * + * A row runs from its `- **Label:**` line up to the next bullet, blank line or + * block quote — the same extent the gate reads, so a literal that a future editor + * pushes onto a continuation line is inside both or outside both. + * + * @returns {{ label: string, start: number, end: number }[]} + */ +function rowSpans(lines) { + const start = lines.findIndex((line) => line.trim() === SECTION_HEADING); + if (start === -1) throw new Error(`${DOC_REL} must still have a "${SECTION_HEADING}" heading`); + + const after = lines.slice(start + 1); + const relEnd = after.findIndex((line) => /^## /.test(line)); + const sectionEnd = relEnd === -1 ? lines.length : start + 1 + relEnd; + + const spans = []; + for (let i = start + 1; i < sectionEnd; i += 1) { + const head = lines[i].match(/^- \*\*(.+?):\*\*/); + if (!head) continue; + let end = i + 1; + while (end < sectionEnd) { + const next = lines[end]; + if (next.trim() === '' || /^- \*\*/.test(next) || /^>/.test(next)) break; + end += 1; + } + spans.push({ label: head[1], start: i, end }); + } + return spans; +} + +/** + * Assigns each derived literal to a slot in the row, identity first. + * + * Literals already correct keep their own slot, so a row where only one of two + * values moved (pnpm's `packageManager` bumping while the floor holds) is edited + * in exactly one place. Only what is left over is filled positionally, which is + * the single spot where the order of `derivedLiteralsByRow` is load-bearing. + * + * @param {string[]} stated normalized literals, in order of appearance + * @param {string[]} derived literals to end up with, in row order + * @returns {(string | null)[]} per slot: the replacement text, or null to keep + */ +export function assignSlots(stated, derived) { + const remaining = [...derived]; + const plan = stated.map((value) => { + const hit = remaining.findIndex((candidate) => normalize(candidate) === value); + if (hit === -1) return undefined; + remaining.splice(hit, 1); + return null; // already correct — leave the row's own spelling alone + }); + return plan.map((slot) => (slot === null ? null : remaining.shift() ?? null)); +} + +/** + * @typedef {object} SyncResult + * @property {string} doc the document, rewritten where it was safe to + * @property {{ label: string, from: string, to: string }[]} changes + * @property {{ label: string, stated: string[], derived: string[], reason: string }[]} refusals + */ + +/** + * Rewrites the anchored rows of `doc` to state `derived`. + * + * @param {string} doc + * @param {Map} derived + * @returns {SyncResult} + */ +export function syncDoc(doc, derived) { + const lines = doc.split('\n'); + const changes = []; + const refusals = []; + + for (const { label, start, end } of rowSpans(lines)) { + const want = derived.get(label); + if (!want) continue; // unanchored (the TypeScript row) — not ours to touch + + const rowLines = lines.slice(start, end); + + // Per-line scanning is what makes the rewrite safe (a replacement never has + // to be split across a newline), but the gate reads the row JOINED. The two + // views can disagree in one place: `≥\n 22` is one literal joined and none + // per line, because the regex's `\s*` matches a newline. Counting both ways + // and refusing on disagreement means this never edits a row it is reading + // differently from the gate that will judge it. + const perLine = rowLines.flatMap((line) => [...line.matchAll(VERSION_LITERAL)].map((m) => m[0])); + const joined = [...rowLines.join(' ').matchAll(VERSION_LITERAL)].map((m) => m[0]); + if (perLine.length !== joined.length) { + refusals.push({ + label, + stated: joined.map(normalize), + derived: want, + reason: 'a version literal spans a line break, so this row reads differently line-by-line than the gate reads it joined', + }); + continue; + } + + const stated = perLine.map(normalize); + const wantKeys = want.map(normalize); + const same = + stated.length === wantKeys.length && [...stated].sort().join('|') === [...wantKeys].sort().join('|'); + if (same) continue; + + if (stated.length !== want.length) { + refusals.push({ + label, + stated, + derived: want, + reason: `the row states ${stated.length} version literal(s) and its anchor derives ${want.length}; ` + + 'which slot gets which value would be a guess', + }); + continue; + } + + const plan = assignSlots(stated, want); + let slot = 0; + for (let i = 0; i < rowLines.length; i += 1) { + rowLines[i] = rowLines[i].replace(VERSION_LITERAL, (match) => { + const replacement = plan[slot]; + const current = match; + slot += 1; + if (replacement === null) return current; + changes.push({ label, from: current, to: replacement }); + return replacement; + }); + } + lines.splice(start, end - start, ...rowLines); + } + + return { doc: lines.join('\n'), changes, refusals }; +} + +export function main(argv = process.argv.slice(2), root = repoRoot) { + const checkOnly = argv.includes('--check'); + const docPath = join(root, DOC_REL); + const before = readFileSync(docPath, 'utf8'); + + let result; + try { + result = syncDoc(before, derivedLiteralsByRow(readAnchors(root))); + } catch (error) { + console.error(`❌ Cannot sync ${DOC_REL}'s "Current Release" block:\n\n ${error.message}\n`); + return 1; + } + + for (const refusal of result.refusals) { + console.error( + `❌ Refusing to rewrite the "${refusal.label}" row of ${DOC_REL}:\n` + + ` ${refusal.reason}\n` + + ` the row states ${JSON.stringify(refusal.stated)}; its anchor derives ${JSON.stringify(refusal.derived)}\n` + + ' Edit the row by hand, then re-run this script.\n', + ); + } + + if (result.doc === before) { + if (result.refusals.length > 0) return 1; + console.log(`✅ ${DOC_REL}'s "Current Release" block already states every anchor.`); + return 0; + } + + for (const change of result.changes) { + console.log(` ${change.label}: ${change.from} → ${change.to}`); + } + + if (checkOnly) { + console.error( + `\n❌ ${DOC_REL}'s "Current Release" block has drifted from its anchors.\n` + + ' Run: node scripts/sync-quick-reference-release.mjs\n', + ); + return 1; + } + + writeFileSync(docPath, result.doc); + console.log(`\n✅ Rewrote ${DOC_REL}'s "Current Release" block from its anchors.`); + return result.refusals.length > 0 ? 1 : 0; +} + +// Only run when invoked as a script — the tests import the functions above. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main()); +}