diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump new file mode 100755 index 000000000..298fab17d --- /dev/null +++ b/bin/gstack-version-bump @@ -0,0 +1,212 @@ +#!/usr/bin/env bun +// gstack-version-bump — deterministic version-state classifier + writer for /ship. +// +// Extracted from ship Step 12 prose (v2 plan T9, hybrid CLI extraction). The +// idempotency classification and the dual-write to VERSION + package.json are +// pure deterministic logic; running them as tested code removes the single +// worst /ship footgun — re-bumping an already-shipped branch — from prose the +// agent could skip or misread when the step lives in a lazy-loaded section. +// +// What STAYS agent judgment (NOT here): the bump-LEVEL decision (micro/patch vs +// minor/major, which may AskUserQuestion on feature signals) and the queue +// collision prompt. The slot pick itself is bin/gstack-next-version. This CLI +// only answers "what state am I in?" and "write this exact version". +// +// Subcommands: +// classify --base [--version-path

] +// Compares VERSION vs origin/:VERSION vs package.json.version. +// Emits JSON: { state, baseVersion, currentVersion, pkgVersion, pkgExists } +// state ∈ FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED +// Exit 0 on a decidable state (incl. DRIFT_UNEXPECTED — it's a real state +// the caller must handle), exit 2 on bad args / unresolvable base. +// +// write --version [--version-path

] +// Validates the 4-digit pattern, writes VERSION + package.json.version. +// Use for the FRESH bump (or an approved queue rebump). Exit 3 on a +// half-write (VERSION written, package.json failed) so the caller knows +// drift exists; the next classify() will report DRIFT_STALE_PKG. +// +// repair [--version-path

] +// DRIFT_STALE_PKG path: sync package.json.version to the current VERSION +// file. No bump. Validates the VERSION pattern first. +// +// Contract: classify NEVER writes. write/repair mutate VERSION + package.json +// only. No git mutation, no network. Mirrors gstack-next-version's reader/writer +// split so /ship composes them. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/; +const DEFAULT = "0.0.0.0"; + +type State = "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED"; + +function fail(msg: string, code = 2): never { + process.stderr.write(`gstack-version-bump: ${msg}\n`); + process.exit(code); +} + +function argVal(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag); + return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; +} + +/** Resolve the VERSION file path: --version-path, else .gstack/version-path, else "VERSION". */ +function resolveVersionPath(cwd: string, explicit?: string): string { + if (explicit) return join(cwd, explicit); + const pin = join(cwd, ".gstack", "version-path"); + if (existsSync(pin)) { + const p = readFileSync(pin, "utf-8").trim(); + if (p) return join(cwd, p); + } + return join(cwd, "VERSION"); +} + +function readVersionFile(p: string): string { + try { + const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, ""); + return v || DEFAULT; + } catch { + return DEFAULT; + } +} + +/** package.json version + existence, parsed without spawning node. */ +function readPkgVersion(cwd: string): { exists: boolean; version: string } { + const pkgPath = join(cwd, "package.json"); + if (!existsSync(pkgPath)) return { exists: false, version: "" }; + let raw: string; + try { + raw = readFileSync(pkgPath, "utf-8"); + } catch { + return { exists: true, version: "" }; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + fail("package.json is not valid JSON. Fix the file before re-running /ship.", 2); + } + const version = (parsed as { version?: unknown })?.version; + return { exists: true, version: typeof version === "string" ? version : "" }; +} + +function writePkgVersion(cwd: string, version: string): void { + const pkgPath = join(cwd, "package.json"); + const raw = readFileSync(pkgPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + parsed.version = version; + writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n"); +} + +function baseVersion(cwd: string, base: string, versionRel: string): string { + // Verify the base ref resolves, mirroring the Step 12 guard. + try { + execFileSync("git", ["rev-parse", "--verify", `origin/${base}`], { cwd, stdio: "ignore" }); + } catch { + fail(`Unable to resolve origin/${base}. Run 'git fetch origin' or verify the base branch exists.`, 2); + } + try { + const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString(); + const v = out.replace(/[\r\n\s]/g, ""); + return v || DEFAULT; + } catch { + // VERSION absent on base (new repo / new file) → treat as 0.0.0.0. + return DEFAULT; + } +} + +function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State { + if (current === base) { + // VERSION unchanged vs base. A diverging package.json means someone hand-edited + // package.json bypassing /ship — unsafe to guess which is authoritative. + if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_UNEXPECTED"; + return "FRESH"; + } + // VERSION already moved past base. + if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG"; + return "ALREADY_BUMPED"; +} + +function cmdClassify(args: string[], cwd: string): void { + const base = argVal(args, "--base"); + if (!base) fail("classify requires --base ", 2); + const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); + const versionRel = argVal(args, "--version-path") ?? "VERSION"; + const current = readVersionFile(versionPath); + const baseV = baseVersion(cwd, base!, versionRel); + const pkg = readPkgVersion(cwd); + const state = classifyState(current, baseV, pkg.exists, pkg.version); + process.stdout.write( + JSON.stringify({ + state, + baseVersion: baseV, + currentVersion: current, + pkgVersion: pkg.version || null, + pkgExists: pkg.exists, + }) + "\n", + ); + // DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the + // classification itself succeeded, so exit 0. (Bad args / unresolvable base are + // the only exit-2 cases.) +} + +function cmdWrite(args: string[], cwd: string): void { + const version = argVal(args, "--version"); + if (!version) fail("write requires --version ", 2); + if (!VERSION_RE.test(version!)) { + fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2); + } + const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); + writeFileSync(versionPath, version + "\n"); + if (existsSync(join(cwd, "package.json"))) { + try { + writePkgVersion(cwd, version!); + } catch { + fail( + "failed to update package.json. VERSION was written but package.json is now stale. " + + "Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.", + 3, + ); + } + } + process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n"); +} + +function cmdRepair(args: string[], cwd: string): void { + const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); + const current = readVersionFile(versionPath); + if (!VERSION_RE.test(current)) { + fail( + `VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH.MICRO. ` + + "Refusing to propagate invalid semver into package.json. Fix VERSION, then re-run /ship.", + 2, + ); + } + if (!existsSync(join(cwd, "package.json"))) { + fail("repair: no package.json to sync.", 2); + } + try { + writePkgVersion(cwd, current); + } catch { + fail("drift repair failed — could not update package.json.", 3); + } + process.stdout.write(JSON.stringify({ repaired: current }) + "\n"); +} + +// Exported for unit tests (pure logic, no I/O). +export { classifyState, VERSION_RE, type State }; + +if (import.meta.main) { + const [sub, ...rest] = process.argv.slice(2); + const cwd = process.cwd(); + switch (sub) { + case "classify": cmdClassify(rest, cwd); break; + case "write": cmdWrite(rest, cwd); break; + case "repair": cmdRepair(rest, cwd); break; + default: + fail("usage: gstack-version-bump [flags]", 2); + } +} diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts new file mode 100644 index 000000000..ffcecd1a7 --- /dev/null +++ b/test/gstack-version-bump.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for the gstack-version-bump CLI (v2 plan T9 hybrid extraction). Covers + * the idempotency classifier (pure) + the write/repair mutations (temp fs). + * The classifier is the one that prevents re-bumping an already-shipped branch — + * the worst /ship footgun — so it gets exhaustive state coverage. + */ + +import { describe, test, expect, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { classifyState, VERSION_RE } from '../bin/gstack-version-bump'; + +const BIN = path.join(import.meta.dir, '..', 'bin', 'gstack-version-bump'); + +describe('classifyState (idempotency)', () => { + test('FRESH when VERSION matches base and pkg agrees', () => { + expect(classifyState('1.1.0.0', '1.1.0.0', true, '1.1.0.0')).toBe('FRESH'); + }); + test('FRESH when VERSION matches base and no package.json', () => { + expect(classifyState('1.1.0.0', '1.1.0.0', false, '')).toBe('FRESH'); + }); + test('ALREADY_BUMPED when VERSION moved past base and pkg agrees (re-run)', () => { + expect(classifyState('1.2.0.0', '1.1.0.0', true, '1.2.0.0')).toBe('ALREADY_BUMPED'); + }); + test('ALREADY_BUMPED when VERSION moved past base, no package.json', () => { + expect(classifyState('1.2.0.0', '1.1.0.0', false, '')).toBe('ALREADY_BUMPED'); + }); + test('DRIFT_STALE_PKG when VERSION bumped but pkg lagging', () => { + expect(classifyState('1.2.0.0', '1.1.0.0', true, '1.1.0.0')).toBe('DRIFT_STALE_PKG'); + }); + test('DRIFT_UNEXPECTED when VERSION matches base but pkg diverges (manual edit)', () => { + expect(classifyState('1.1.0.0', '1.1.0.0', true, '1.2.0.0')).toBe('DRIFT_UNEXPECTED'); + }); +}); + +describe('VERSION_RE', () => { + test('accepts 4-digit semver', () => { + expect(VERSION_RE.test('1.2.3.4')).toBe(true); + }); + test('rejects 3-digit and garbage', () => { + expect(VERSION_RE.test('1.2.3')).toBe(false); + expect(VERSION_RE.test('v1.2.3.4')).toBe(false); + expect(VERSION_RE.test('1.2.3.4-rc')).toBe(false); + }); +}); + +describe('write (FRESH bump)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-write-')); + afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + test('writes VERSION + package.json.version, preserving other pkg fields', () => { + fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0', scripts: { t: 'y' } }, null, 2) + '\n'); + const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString(); + expect(JSON.parse(out)).toEqual({ wrote: '1.1.0.0', packageJson: true }); + expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('1.1.0.0'); + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')); + expect(pkg.version).toBe('1.1.0.0'); + expect(pkg.scripts).toEqual({ t: 'y' }); // untouched + }); + + test('rejects a malformed version with exit 2', () => { + let code = 0; + try { execFileSync('bun', [BIN, 'write', '--version', '1.2.3'], { cwd: dir, stdio: 'pipe' }); } + catch (e: any) { code = e.status; } + expect(code).toBe(2); + }); + + test('VERSION-only repo (no package.json) writes just VERSION', () => { + const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-')); + fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n'); + const out = execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d2 }).toString(); + expect(JSON.parse(out)).toEqual({ wrote: '0.2.0.0', packageJson: false }); + expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0'); + fs.rmSync(d2, { recursive: true, force: true }); + }); +}); + +describe('repair (DRIFT_STALE_PKG)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-repair-')); + afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + test('syncs package.json.version up to VERSION, no re-bump', () => { + fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0.0' }, null, 2) + '\n'); + const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString(); + expect(JSON.parse(out)).toEqual({ repaired: '2.0.0.0' }); + expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0.0'); + expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('2.0.0.0'); // unchanged + }); + + test('refuses to propagate an invalid VERSION (exit 2)', () => { + fs.writeFileSync(path.join(dir, 'VERSION'), 'not-a-version\n'); + let code = 0; + try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' }); } + catch (e: any) { code = e.status; } + expect(code).toBe(2); + }); +}); + +describe('classify (idempotency over a real git base)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-classify-')); + afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + // Build a tiny repo with an "origin/main" carrying VERSION=1.0.0.0. + const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe' }); + fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0' }, null, 2) + '\n'); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); + git('add', '-A'); git('commit', '-q', '-m', 'base'); + // Fake an "origin/main" remote-tracking ref pointing at this commit. + const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim(); + fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n'); + + test('reports FRESH before any bump', () => { + const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString(); + expect(JSON.parse(out).state).toBe('FRESH'); + }); + + test('reports ALREADY_BUMPED after VERSION+pkg move together', () => { + fs.writeFileSync(path.join(dir, 'VERSION'), '1.1.0.0\n'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.1.0.0' }, null, 2) + '\n'); + const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString(); + const parsed = JSON.parse(out); + expect(parsed.state).toBe('ALREADY_BUMPED'); + expect(parsed.baseVersion).toBe('1.0.0.0'); + expect(parsed.currentVersion).toBe('1.1.0.0'); + }); +});