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:
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);