Archilyzer · Source

archilyzer

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

commit bee55a498b270eef10993bbd138af6558d2f22e9
parent 59ae09eea6ee07aeaccfafbb57bddcf6c87e36a1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 19:03:51 -0400

common: archilyzer release show [editor|export] and release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date] — the local release cutter, no editor needed

`show` prints the latest release, its date, the pending bullet count and what
next / next-minor would be (plus the `all` line, against the higher latest);
`cut` prints one line per changelog (`editor: ## [0.10.0] - 2026-09-26
(committed 1a2b3c4d)`), refuses bad arguments with 2 before reading anything,
and exits 1 on any failure. bin/release.ts over controller/cutRelease;
_cli.test.ts +6 (the rows, the flags, usage, parsing, the printed lines).

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

Diffstat:
Mcommon/bin/_cli.test.ts | 178++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/bin/archilyzer.ts | 17+++++++++++++++++
Acommon/bin/release.ts | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 358 insertions(+), 2 deletions(-)

diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts @@ -9,12 +9,23 @@ import { usage, type Command, } from "./_cli"; +import { COMMANDS } from "./archilyzer"; +import { + cutMain, + formatAllLine, + formatCutLines, + formatShowLine, + parseCutArgs, + parseShowArgs, + showMain, +} from "./release"; // Run with: // pnpm --filter yt-dlp-transcript-common test // -// The archilyzer CLI's parser and lookup. The command table itself -// (archilyzer.ts) is not imported: these are the rules every row obeys. +// The archilyzer CLI's parser and lookup — the rules every row obeys. The +// command table itself (archilyzer.ts) is imported only at the end, for the +// `release` rows, which the release runbook's step 0 calls by name. function cmd(path: string[], extra: Partial<Command> = {}): Command { return { @@ -118,3 +129,166 @@ test("runCli runs the command with the rest, and refuses with 2 before running", assert.equal(await runCli(table, ["build", "site", "--help"], {}, quiet), 0); assert.equal(seen.length, 2, "no refused call reached run"); }); + +// --- archilyzer release (release 10 slice P) -------------------------------- + +test("release cut and release show are table rows; --commit never swallows a positional", () => { + const cut = resolveCommand(COMMANDS, ["release", "cut", "all", "next"]); + assert.deepEqual(cut?.command.path, ["release", "cut"]); + assert.deepEqual(cut?.rest, ["all", "next"]); + assert.deepEqual(resolveCommand(COMMANDS, ["release", "show"])?.command.path, [ + "release", + "show", + ]); + const parsed = parseArgv( + ["release", "cut", "--commit", "all", "next", "--date", "2026-09-26"], + booleanFlags(COMMANDS), + ); + assert.deepEqual(parsed, { + positionals: ["release", "cut", "all", "next"], + flags: { commit: true, date: "2026-09-26" }, + }); + const command = cut!.command; + assert.equal(argumentProblem(command, parsed.flags, ["all", "next"]), null); + assert.match(argumentProblem(command, { date: true }, [])!, /--date needs a value/); + assert.match(argumentProblem(command, { tag: true }, [])!, /unknown flag --tag \(accepts --commit, --date\)/); + assert.match(argumentProblem(command, {}, ["all", "next", "x"])!, /unexpected argument "x"/); +}); + +test("usage lists release show and release cut with their arguments", () => { + const u = usage(COMMANDS); + assert.match(u, /archilyzer release show\s+\[editor\|export\]/); + assert.match( + u, + /archilyzer release cut\s+<editor\|export\|all> <X\.Y\.Z\|next\|next-minor> \[--commit\] \[--date YYYY-MM-DD\]/, + ); +}); + +test("parseCutArgs checks the changelog, the version and the date before anything is read", () => { + assert.deepEqual(parseCutArgs(["all", "next"], { commit: true }), { + target: "all", + version: "next", + commit: true, + }); + assert.deepEqual(parseCutArgs(["editor", "0.10.0"], { date: "2026-09-25" }), { + target: "editor", + version: "0.10.0", + commit: false, + date: "2026-09-25", + }); + const error = (p: string[], f = {}) => { + const r = parseCutArgs(p, f); + return "error" in r ? r.error : ""; + }; + assert.match(error([]), /which changelog\? editor, export or all/); + assert.match(error(["site", "next"]), /"site" is not editor, export or all/); + 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.deepEqual(parseShowArgs([]), ["editor", "export"]); + assert.deepEqual(parseShowArgs(["all"]), ["editor", "export"]); + assert.deepEqual(parseShowArgs(["export"]), ["export"]); + assert.match((parseShowArgs(["hub"]) as { error: string }).error, /"hub" is not editor or export/); +}); + +test("a refused release command exits 2 and prints why, touching nothing", async () => { + const errors: string[] = []; + const out = { log: () => {}, error: (s: string) => errors.push(s) }; + const ctx = (positionals: string[]) => ({ positionals, flags: {}, env: {} }); + assert.equal(await cutMain(ctx(["site", "next"]), out), 2); + assert.equal(await showMain(ctx(["hub"]), out), 2); + assert.equal(errors.length, 2); +}); + +test("release cut prints one line per changelog: the heading and the commit, the refusal, what a failure stopped", () => { + assert.deepEqual( + formatCutLines({ + ok: false, + version: "0.10.0", + results: [ + { + ok: true, + workspace: "editor", + version: "0.10.0", + heading: "## [0.10.0] - 2026-09-26", + committed: true, + commitSha: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + }, + { ok: false, workspace: "export", error: "Nothing pending to release under [Unreleased]." }, + ], + notAttempted: [], + }).map((l) => l.line), + [ + "editor: ## [0.10.0] - 2026-09-26 (committed 1a2b3c4d)", + "export: failed — Nothing pending to release under [Unreleased].", + ], + ); + assert.deepEqual( + formatCutLines({ + ok: false, + version: "0.9.1", + results: [{ ok: false, workspace: "editor", error: "boom" }], + notAttempted: ["export"], + }), + [ + { line: "editor: failed — boom", failed: true }, + { line: "export: not cut — stopped at the failure above", failed: true }, + ], + ); + assert.deepEqual( + formatCutLines({ + ok: true, + version: "0.9.1", + results: [ + { + ok: true, + workspace: "export", + version: "0.9.1", + heading: "## [0.9.1] - 2026-09-26", + committed: false, + }, + ], + notAttempted: [], + }), + [{ line: "export: ## [0.9.1] - 2026-09-26 (not committed)", failed: false }], + ); +}); + +test("release show says the latest release, its date and what is pending, and what all would cut", () => { + const editor = { + ok: true as const, + workspace: "editor" as const, + file: "/r/editor/CHANGELOG.md", + latest: { version: "0.9.0", date: "2026-09-26" }, + hasUnreleased: true, + pending: 8, + next: "0.9.1", + nextMinor: "0.10.0", + }; + const exp = { + ...editor, + workspace: "export" as const, + latest: { version: "0.9.2", date: null }, + pending: 1, + next: "0.9.3", + nextMinor: "0.10.0", + }; + assert.equal( + formatShowLine(editor), + "editor: latest 0.9.0 (2026-09-26); 8 bullets pending under [Unreleased]; next 0.9.1, next-minor 0.10.0", + ); + assert.equal( + formatShowLine(exp), + "export: latest 0.9.2 (undated); 1 bullet pending under [Unreleased]; next 0.9.3, next-minor 0.10.0", + ); + assert.match(formatShowLine({ ...editor, pending: 0 }), /; nothing pending under \[Unreleased\];/); + assert.match(formatShowLine({ ...editor, hasUnreleased: false, pending: 0 }), /; no \[Unreleased\] heading;/); + assert.match(formatShowLine({ ...editor, latest: null }), /^editor: no release yet;/); + assert.equal( + formatShowLine({ ok: false, workspace: "export", file: "/x", error: "Could not read /x: ENOENT" }), + "export: Could not read /x: ENOENT", + ); + // `all` resolves against the HIGHER latest, as cutReleases does. + assert.equal(formatAllLine([editor, exp]), "all: next 0.9.3, next-minor 0.10.0"); + assert.equal(formatAllLine([editor]), null); +}); diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -183,6 +183,23 @@ export const COMMANDS: Command[] = [ run: async ({ flags }) => (await import("./settings-example")).main({ check: flags.check === true }), }, + // Release notes, cut locally: the same writer as the /sites and /changelog + // form and POST /api/ops/cut-release, with no editor running (release.ts). + { + path: ["release", "show"], + usage: + "[editor|export] latest release, its date, how many bullets wait under [Unreleased]", + maxPositionals: 1, + run: async (ctx) => (await import("./release")).showMain(ctx), + }, + { + path: ["release", "cut"], + usage: + "<editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD] [Unreleased] -> a dated heading; all = both, one version, two commits", + flags: { commit: "boolean", date: "string" }, + maxPositionals: 2, + run: async (ctx) => (await import("./release")).cutMain(ctx), + }, ]; // The site a site command names: its argument, else SITE_ID (which is how diff --git a/common/bin/release.ts b/common/bin/release.ts @@ -0,0 +1,165 @@ +// `archilyzer release cut | show` — cut a release from the command line, with +// no editor running (release 10 slice P). The writer is +// common/controller/cutRelease.ts, the same one the "Cut release" form and +// POST /api/ops/cut-release call; this file is argument checking and one +// printed line per changelog. +// +// archilyzer release show [editor|export] +// archilyzer release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD] +// +// It works on the checkout it is run from (the monorepo root above the cwd; +// EDITOR_CHANGELOG_FILE / EXPORT_CHANGELOG_FILE are honoured as everywhere). + +import type { CommandContext } from "./_cli"; +import type { FlagValue } from "./_parseFlags"; +import { compareVersionCores, suggestNextMinorVersion, suggestNextVersion } from "../lib/changelog"; +import { + cutReleases, + dateProblem, + describeRelease, + isReleaseTarget, + RELEASE_WORKSPACES, + versionSpecProblem, + type CutReleasesOutcome, + type ReleaseSummary, + type ReleaseTarget, + type ReleaseWorkspace, +} from "../controller/cutRelease"; + +type Out = { log: (s: string) => void; error: (s: string) => void }; + +export type CutArgs = { + target: ReleaseTarget; + version: string; + commit: boolean; + date?: string; +}; + +/** `release cut`'s arguments, checked before anything is read — or why not. */ +export function parseCutArgs( + positionals: readonly string[], + flags: Record<string, FlagValue>, +): CutArgs | { error: string } { + const [target, version] = positionals; + if (!target) { + return { error: "release cut: which changelog? editor, export or all" }; + } + if (!isReleaseTarget(target)) { + return { error: `release cut: "${target}" is not editor, export or all` }; + } + if (!version) { + return { error: "release cut: which version? X.Y.Z, next or next-minor" }; + } + const versionProblem = versionSpecProblem(version); + if (versionProblem) return { error: `release cut: ${versionProblem}` }; + const date = typeof flags.date === "string" ? flags.date.trim() : undefined; + if (date !== undefined) { + const problem = dateProblem(date); + if (problem) return { error: `release cut: ${problem}` }; + } + return { + target, + version: version.trim(), + commit: flags.commit === true, + ...(date !== undefined ? { date } : {}), + }; +} + +/** `release show`'s changelogs: the one named, or both (`all` is both too). */ +export function parseShowArgs( + positionals: readonly string[], +): ReleaseWorkspace[] | { error: string } { + const [which] = positionals; + if (!which || which === "all") return [...RELEASE_WORKSPACES]; + if (which === "editor" || which === "export") return [which]; + return { error: `release show: "${which}" is not editor or export` }; +} + +/** + * One line per changelog: the heading now in the file and whether it was + * committed; the refusal; and any changelog a failure stopped before. + */ +export function formatCutLines(outcome: CutReleasesOutcome): { line: string; failed: boolean }[] { + const lines = outcome.results.map((r) => + r.ok + ? { + line: `${r.workspace}: ${r.heading} (${ + r.committed + ? r.commitSha + ? `committed ${r.commitSha.slice(0, 8)}` + : "committed" + : "not committed" + })`, + failed: false, + } + : { line: `${r.workspace}: failed — ${r.error}`, failed: true }, + ); + for (const ws of outcome.notAttempted) { + lines.push({ line: `${ws}: not cut — stopped at the failure above`, failed: true }); + } + return lines; +} + +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +/** The step-0 check, one line: latest release, its date, what is pending. */ +export function formatShowLine(s: ReleaseSummary): string { + if (!s.ok) return `${s.workspace}: ${s.error}`; + const latest = s.latest + ? `latest ${s.latest.version} (${s.latest.date ?? "undated"})` + : "no release yet"; + const pending = !s.hasUnreleased + ? "no [Unreleased] heading" + : s.pending === 0 + ? "nothing pending under [Unreleased]" + : `${plural(s.pending, "bullet")} pending under [Unreleased]`; + return `${s.workspace}: ${latest}; ${pending}; next ${s.next}, next-minor ${s.nextMinor}`; +} + +/** + * What `release cut all next` would cut: the keywords against the HIGHER of + * the two latest headings (cutReleases' rule), or null unless both were read. + */ +export function formatAllLine(summaries: ReleaseSummary[]): string | null { + if (summaries.length < 2 || summaries.some((s) => !s.ok)) return 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; + } + return `all: next ${suggestNextVersion(highest)}, next-minor ${suggestNextMinorVersion(highest)}`; +} + +export async function cutMain(ctx: CommandContext, out: Out = console): Promise<number> { + const args = parseCutArgs(ctx.positionals, ctx.flags); + if ("error" in args) { + out.error(args.error); + return 2; + } + const outcome = await cutReleases({ + workspace: args.target, + version: args.version, + commit: args.commit, + date: args.date, + }); + for (const { line, failed } of formatCutLines(outcome)) { + (failed ? out.error : out.log)(line); + } + return outcome.ok ? 0 : 1; +} + +export async function showMain(ctx: CommandContext, out: Out = console): Promise<number> { + const which = parseShowArgs(ctx.positionals); + if ("error" in which) { + out.error(which.error); + return 2; + } + const summaries: ReleaseSummary[] = []; + for (const ws of which) summaries.push(await describeRelease(ws)); + for (const s of summaries) (s.ok ? out.log : out.error)(formatShowLine(s)); + const all = formatAllLine(summaries); + if (all) out.log(all); + return summaries.every((s) => s.ok) ? 0 : 1; +}