Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 2ffebd1bf45817d76be456fd7a5365746f33d155
parent 09aba7056f3ee0828fd6706b9f7fc050f0097283
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 19:30:26 -0400

common: a release cut refuses an impossible date and a version that does not move forward (review L1 + L2), in cutRelease itself so every caller gets it

`dateISOProblem`: YYYY-MM-DD AND a calendar day (a Date.UTC round trip), so
`--date 2026-13-45` / `2026-02-30` are refused — the controller's
`dateProblem` is now the same function. `cutRelease` refuses a version at or
below the latest heading: "Version 0.9.0 is not newer than the latest
release, 0.9.0." `compareVersions` (semver precedence, prerelease below its
release) replaces compareVersionCores, so 1.0.0-rc.1 is above 0.9.0 and
0.9.0-rc.1 is not; `all`'s keyword resolution and `release show`'s `all:`
line use it too. Tests: changelog +2 (and the compare test rewritten),
cutRelease +1 (and the date case), _cli +1 assertion.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/bin/_cli.test.ts | 1+
Mcommon/bin/release.ts | 4++--
Mcommon/controller/cutRelease.test.ts | 44+++++++++++++++++++++++++++++++++++++++++++-
Mcommon/controller/cutRelease.ts | 13++++++++-----
Mcommon/lib/changelog.test.ts | 51+++++++++++++++++++++++++++++++++++++++++++--------
Mcommon/lib/changelog.ts | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
6 files changed, 169 insertions(+), 35 deletions(-)

diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts @@ -185,6 +185,7 @@ test("parseCutArgs checks the changelog, the version and the date before anythin assert.match(error(["editor"]), /which version\? X\.Y\.Z, next or next-minor/); assert.match(error(["editor", "v1"]), /"v1" is not a valid semver/); assert.match(error(["editor", "next"], { date: "yesterday" }), /"yesterday" is not in YYYY-MM-DD form/); + assert.match(error(["editor", "next"], { date: "2026-13-45" }), /"2026-13-45" is not a calendar date/); assert.deepEqual(parseShowArgs([]), ["editor", "export"]); assert.deepEqual(parseShowArgs(["all"]), ["editor", "export"]); assert.deepEqual(parseShowArgs(["export"]), ["export"]); diff --git a/common/bin/release.ts b/common/bin/release.ts @@ -12,7 +12,7 @@ import type { CommandContext } from "./_cli"; import type { FlagValue } from "./_parseFlags"; -import { compareVersionCores, suggestNextMinorVersion, suggestNextVersion } from "../lib/changelog"; +import { compareVersions, suggestNextMinorVersion, suggestNextVersion } from "../lib/changelog"; import { cutReleases, dateProblem, @@ -127,7 +127,7 @@ export function formatAllLine(summaries: ReleaseSummary[]): string | null { let highest: string | null = null; for (const s of summaries) { const v = s.ok ? (s.latest?.version ?? null) : null; - if (compareVersionCores(v, highest) > 0) highest = v; + if (compareVersions(v, highest) > 0) highest = v; } return `all: next ${suggestNextVersion(highest)}, next-minor ${suggestNextMinorVersion(highest)}`; } diff --git a/common/controller/cutRelease.test.ts b/common/controller/cutRelease.test.ts @@ -309,7 +309,37 @@ test("all stops at the first failure and reports what was already done", async ( ); }); -test("--date is used verbatim, and a malformed one is refused", async () => { +test("a literal version must move forward from the latest heading; the refusal names both", async () => { + const source = changelog({ pending: ["- x"], latest: "0.9.0" }); + await withRepo({ editor: source }, async (root) => { + for (const version of ["0.9.0", "0.8.9", "0.9.0-rc.1"]) { + const result = await cutReleaseForWorkspace({ + workspace: "editor", + version, + commit: true, + date: DATE, + root, + }); + assert.deepEqual(result, { + ok: false, + workspace: "editor", + error: `Version ${version} is not newer than the latest release, 0.9.0.`, + }); + } + assert.equal(await read(root, "editor"), source); + assert.deepEqual(await subjects(root), ["init"]); + const pre = await cutReleaseForWorkspace({ + workspace: "editor", + version: "1.0.0-rc.1", + commit: false, + date: DATE, + root, + }); + assert.equal(pre.ok && pre.heading, `## [1.0.0-rc.1] - ${DATE}`); + }); +}); + +test("--date is used verbatim, and a malformed or impossible one is refused", async () => { await withRepo( { editor: changelog({ pending: ["- x"], latest: "0.9.0" }) }, async (root) => { @@ -325,6 +355,18 @@ test("--date is used verbatim, and a malformed one is refused", async () => { workspace: "editor", error: 'Date "26/09/2026" is not in YYYY-MM-DD form.', }); + const impossible = await cutReleaseForWorkspace({ + workspace: "editor", + version: "0.9.1", + commit: false, + date: "2026-13-45", + root, + }); + assert.deepEqual(impossible, { + ok: false, + workspace: "editor", + error: 'Date "2026-13-45" is not a calendar date.', + }); const good = await cutReleaseForWorkspace({ workspace: "editor", version: "0.9.1", diff --git a/common/controller/cutRelease.ts b/common/controller/cutRelease.ts @@ -2,10 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import { getPaths } from "../lib/paths"; import { - compareVersionCores, + compareVersions, countUnreleasedBullets, cutRelease, CutReleaseError, + dateISOProblem, getLatestRelease, getLatestReleasedVersion, hasUnreleasedHeading, @@ -42,7 +43,6 @@ export const VERSION_KEYWORDS = ["next", "next-minor"] as const; export type VersionKeyword = (typeof VERSION_KEYWORDS)[number]; const SEMVER_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; -const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; export function isReleaseTarget(v: unknown): v is ReleaseTarget { return (RELEASE_TARGETS as readonly unknown[]).includes(v); @@ -63,9 +63,12 @@ export function versionSpecProblem(version: string): string | null { return `Version "${v}" is not a valid semver (expected X.Y.Z or X.Y.Z-prerelease), "next" or "next-minor".`; } -/** Why `date` cannot stamp a heading — or null. */ +/** + * Why `date` cannot stamp a heading — or null: YYYY-MM-DD and a real day. + * The same check `cutRelease` makes, asked before anything is read. + */ export function dateProblem(date: string): string | null { - return ISO_DATE.test(date) ? null : `Date "${date}" is not in YYYY-MM-DD form.`; + return dateISOProblem(date); } /** Today in LOCAL time, as the form has always stamped it. */ @@ -294,7 +297,7 @@ export async function cutReleases( }; } const latest = getLatestReleasedVersion(read.source); - if (compareVersionCores(latest, highest) > 0) highest = latest; + if (compareVersions(latest, highest) > 0) highest = latest; } version = resolveVersion(version, highest); } diff --git a/common/lib/changelog.test.ts b/common/lib/changelog.test.ts @@ -1,8 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { - compareVersionCores, + compareVersions, countUnreleasedBullets, + cutRelease, + dateISOProblem, getLatestRelease, hasUnreleasedHeading, suggestNextMinorVersion, @@ -37,13 +39,46 @@ test("suggestNextMinorVersion bumps the minor and zeroes the patch", () => { assert.equal(suggestNextVersion("0.9.0"), "0.9.1"); }); -test("compareVersionCores orders by X.Y.Z, numerically", () => { - assert.ok(compareVersionCores("0.10.0", "0.9.9") > 0); - assert.ok(compareVersionCores("0.8.7", "0.9.0") < 0); - assert.equal(compareVersionCores("1.0.0-rc.1", "1.0.0"), 0); - assert.ok(compareVersionCores("0.1.0", null) > 0); - assert.ok(compareVersionCores(null, "0.1.0") < 0); - assert.equal(compareVersionCores(null, null), 0); +test("compareVersions is semver precedence: X.Y.Z numerically, a release above its prereleases", () => { + assert.ok(compareVersions("0.10.0", "0.9.9") > 0); + assert.ok(compareVersions("0.8.7", "0.9.0") < 0); + assert.equal(compareVersions("0.9.0", "0.9.0"), 0); + assert.ok(compareVersions("1.0.0", "1.0.0-rc.1") > 0); + assert.ok(compareVersions("1.0.0-rc.1", "0.9.0") > 0); + assert.ok(compareVersions("0.9.0-rc.1", "0.9.0") < 0); + assert.ok(compareVersions("1.0.0-rc.2", "1.0.0-rc.10") < 0); + assert.ok(compareVersions("1.0.0-alpha", "1.0.0-alpha.1") < 0); + assert.ok(compareVersions("1.0.0-1", "1.0.0-alpha") < 0); + assert.ok(compareVersions("1.0.0-beta", "1.0.0-alpha") > 0); + assert.ok(compareVersions("0.1.0", null) > 0); + assert.ok(compareVersions(null, "0.1.0") < 0); + assert.equal(compareVersions(null, null), 0); +}); + +test("dateISOProblem wants YYYY-MM-DD and a day on the calendar", () => { + assert.equal(dateISOProblem("2026-09-26"), null); + assert.equal(dateISOProblem("2024-02-29"), null); + assert.equal(dateISOProblem("26/09/2026"), 'Date "26/09/2026" is not in YYYY-MM-DD form.'); + assert.equal(dateISOProblem("2026-13-45"), 'Date "2026-13-45" is not a calendar date.'); + assert.equal(dateISOProblem("2026-02-30"), 'Date "2026-02-30" is not a calendar date.'); + assert.equal(dateISOProblem("2025-02-29"), 'Date "2025-02-29" is not a calendar date.'); + assert.equal(dateISOProblem("2026-00-10"), 'Date "2026-00-10" is not a calendar date.'); +}); + +test("cutRelease refuses an impossible date and a version that does not move forward", () => { + assert.throws(() => cutRelease(SOURCE, "0.9.1", "2026-13-45"), /"2026-13-45" is not a calendar date/); + assert.throws( + () => cutRelease(SOURCE, "0.9.0", "2026-09-26"), + /^CutReleaseError: Version 0\.9\.0 is not newer than the latest release, 0\.9\.0\.$/, + ); + assert.throws(() => cutRelease(SOURCE, "0.8.9", "2026-09-26"), /not newer than the latest release, 0\.9\.0/); + assert.throws(() => cutRelease(SOURCE, "0.9.0-rc.1", "2026-09-26"), /not newer/); + // A prerelease of a later version is forward; so is the first release of all. + assert.match(cutRelease(SOURCE, "1.0.0-rc.1", "2026-09-26"), /^## \[1\.0\.0-rc\.1\] - 2026-09-26$/m); + assert.match( + cutRelease("# Changelog\n\n## [Unreleased]\n- first\n", "0.1.0", "2026-09-26"), + /^## \[0\.1\.0\] - 2026-09-26$/m, + ); }); test("getLatestRelease reads the newest released heading and its date", () => { diff --git a/common/lib/changelog.ts b/common/lib/changelog.ts @@ -42,34 +42,80 @@ export function suggestNextVersion(current: string | null): string { * version) is `0.1.0`, as `suggestNextVersion` has it. */ export function suggestNextMinorVersion(current: string | null): string { - const core = versionCore(current); - if (!core) return "0.1.0"; - const [major, minor] = core; + const parsed = parseVersion(current); + if (!parsed) return "0.1.0"; + const [major, minor] = parsed.core; return `${major}.${minor + 1}.0`; } -// [major, minor, patch] of a released version, prerelease dropped — or null. -function versionCore(v: string | null): [number, number, number] | null { - if (!v) return null; - const parts = v.split("-")[0].split(".").map((p) => Number.parseInt(p, 10)); - if (parts.length !== 3 || parts.some(Number.isNaN)) return null; - return [parts[0], parts[1], parts[2]]; +const SEMVER_PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; + +// A heading's version, split for precedence: [major, minor, patch] and the +// prerelease identifiers (empty for a release). +function parseVersion(v: string | null): { core: number[]; pre: string[] } | null { + const m = v ? SEMVER_PARTS.exec(v) : null; + if (!m) return null; + return { + core: [m[1], m[2], m[3]].map((p) => Number.parseInt(p, 10)), + pre: m[4] ? m[4].split(".") : [], + }; } /** - * Orders two released versions by their X.Y.Z cores (a prerelease suffix is - * ignored; an unreadable version sorts first). Negative, zero or positive. + * Semver precedence of two heading versions: X.Y.Z numerically, then a release + * above any prerelease of the same X.Y.Z, then the prerelease identifiers + * (numeric ones numerically and below alphanumeric ones). An unreadable + * version sorts first. Negative, zero or positive. */ -export function compareVersionCores(a: string | null, b: string | null): number { - const ca = versionCore(a); - const cb = versionCore(b); - if (!ca || !cb) return (ca ? 1 : 0) - (cb ? 1 : 0); +export function compareVersions(a: string | null, b: string | null): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + if (!pa || !pb) return (pa ? 1 : 0) - (pb ? 1 : 0); for (let i = 0; i < 3; i++) { - if (ca[i] !== cb[i]) return ca[i] - cb[i]; + if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]; + } + if (!pa.pre.length || !pb.pre.length) { + return (pa.pre.length ? 0 : 1) - (pb.pre.length ? 0 : 1); + } + for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) { + const x = pa.pre[i]; + const y = pb.pre[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const xNum = /^\d+$/.test(x); + const yNum = /^\d+$/.test(y); + if (xNum && yNum) { + const d = Number(x) - Number(y); + if (d !== 0) return d; + } else if (xNum !== yNum) { + return xNum ? -1 : 1; + } else if (x !== y) { + return x < y ? -1 : 1; + } } return 0; } +/** + * Why `date` cannot stamp a release heading — or null. It must be YYYY-MM-DD + * AND a day on the calendar: `2026-13-45` and `2026-02-30` are refused. + */ +export function dateISOProblem(date: string): string | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return `Date "${date}" is not in YYYY-MM-DD form.`; + } + const [y, m, d] = date.split("-").map((p) => Number.parseInt(p, 10)); + const day = new Date(Date.UTC(y, m - 1, d)); + if ( + day.getUTCFullYear() !== y || + day.getUTCMonth() !== m - 1 || + day.getUTCDate() !== d + ) { + return `Date "${date}" is not a calendar date.`; + } + return null; +} + const RELEASED_HEADING_DATED = /^##\s+\[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\](?:\s+-\s+(\d{4}-\d{2}-\d{2}))?/m; @@ -124,9 +170,8 @@ export function cutRelease( `Version "${version}" is not a valid semver (expected X.Y.Z or X.Y.Z-prerelease).`, ); } - if (!/^\d{4}-\d{2}-\d{2}$/.test(dateISO)) { - throw new CutReleaseError(`Date "${dateISO}" is not in YYYY-MM-DD form.`); - } + const dateProblem = dateISOProblem(dateISO); + if (dateProblem) throw new CutReleaseError(dateProblem); const match = UNRELEASED_HEADING.exec(source); if (!match) { throw new CutReleaseError( @@ -137,6 +182,14 @@ export function cutRelease( if (!unreleased) { throw new CutReleaseError("Nothing pending to release under [Unreleased]."); } + // A release goes FORWARD: a version at or below the latest heading would + // file the notes under a release that already shipped (or before it). + const latest = getLatestReleasedVersion(source); + if (latest && compareVersions(version, latest) <= 0) { + throw new CutReleaseError( + `Version ${version} is not newer than the latest release, ${latest}.`, + ); + } const headingStart = match.index; const headingEnd = headingStart + match[0].length; const before = source.slice(0, headingStart);