This commit is contained in:
Carrington 2026-08-15 18:28:49 -04:00 committed by GitHub
commit 2d88ab3a2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 279 additions and 31 deletions

View File

@ -30,13 +30,28 @@
// 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.
// Contract: classify NEVER writes. write/repair mutate VERSION + the manifest
// (+ its lockfile) only. No git mutation, no network. Mirrors
// gstack-next-version's reader/writer split so /ship composes them.
//
// Manifest resolution (all three subcommands accept --package-json-path):
// --package-json-path <p> → .gstack/package-json-path → ./package.json
// A repo whose only Node package lives in a subdirectory (web/, app/,
// frontend/) has no ROOT package.json. The tool used to report
// pkgExists:false there and write VERSION alone, leaving the manifest to be
// bumped by hand — the drift this tool exists to prevent, in the one layout
// where it silently did nothing.
//
// npm semver: VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm rejects a
// fourth component. When a package-lock.json sits beside the manifest — proof
// npm actually manages it — the MICRO is dropped and the lockfile's two
// version fields are mirrored too. Without a lockfile nothing validates the
// field and the historical 1:1 mirror is preserved, so gstack's own
// package.json keeps carrying 1.60.1.0.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { dirname, join, relative } from "node:path";
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
const DEFAULT = "0.0.0.0";
@ -73,9 +88,48 @@ function readVersionFile(p: string): string {
}
}
/**
* Resolve the package.json path: --package-json-path, else
* .gstack/package-json-path, else "package.json".
*
* Mirrors resolveVersionPath. A repo whose only Node package lives in a
* subdirectory (web/, app/, frontend/) has no ROOT package.json, so the
* old join(cwd, "package.json") reported pkgExists:false and every bump
* silently wrote VERSION alone — leaving the manifest to be edited by
* hand, which is exactly the drift this tool exists to prevent.
*/
function resolvePkgPath(cwd: string, explicit?: string): string {
if (explicit) return join(cwd, explicit);
const pin = join(cwd, ".gstack", "package-json-path");
if (existsSync(pin)) {
const p = readFileSync(pin, "utf-8").trim();
if (p) return join(cwd, p);
}
return join(cwd, "package.json");
}
/** The npm lockfile beside a manifest, or "" when there is none. */
function lockPathFor(pkgPath: string): string {
const lock = join(dirname(pkgPath), "package-lock.json");
return existsSync(lock) ? lock : "";
}
/**
* The version string to write INTO a manifest.
*
* VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm's semver is 3-component
* and rejects a fourth. A package-lock.json beside the manifest is proof
* that npm actually manages it, so the MICRO is dropped there. Without a
* lockfile nothing validates the field and the historical 1:1 mirror is
* preserved — gstack's own package.json carries 1.60.1.0 and must keep
* doing so.
*/
function manifestVersion(version: string, npmManaged: boolean): string {
return npmManaged ? version.split(".").slice(0, 3).join(".") : version;
}
/** package.json version + existence, parsed without spawning node. */
function readPkgVersion(cwd: string): { exists: boolean; version: string } {
const pkgPath = join(cwd, "package.json");
function readPkgVersion(pkgPath: string): { exists: boolean; version: string } {
if (!existsSync(pkgPath)) return { exists: false, version: "" };
let raw: string;
try {
@ -87,20 +141,36 @@ function readPkgVersion(cwd: string): { exists: boolean; version: string } {
try {
parsed = JSON.parse(raw);
} catch {
fail("package.json is not valid JSON. Fix the file before re-running /ship.", 2);
fail(`${pkgPath} 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");
function writePkgVersion(pkgPath: string, version: string): void {
const raw = readFileSync(pkgPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
parsed.version = version;
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n");
}
/**
* Mirror the manifest version into package-lock.json.
*
* npm records it twice — at the document root and again under
* `packages[""]`, the entry describing the root package itself — and
* `npm install` keeps both in step. Nothing else in a release does, so a
* lockfile left behind drifts one field per bump until someone runs npm.
* Pure JSON edit: no npm spawn, no dependency-tree churn.
*/
function writeLockVersion(lockPath: string, version: string): void {
const parsed = JSON.parse(readFileSync(lockPath, "utf-8")) as Record<string, unknown>;
parsed.version = version;
const packages = parsed.packages as Record<string, { version?: string }> | undefined;
if (packages && packages[""]) packages[""].version = version;
writeFileSync(lockPath, 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 {
@ -118,15 +188,28 @@ function baseVersion(cwd: string, base: string, versionRel: string): string {
}
}
function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State {
/**
* `expectedPkg` is what the manifest SHOULD hold for the current VERSION.
* It defaults to VERSION itself (the historical 1:1 mirror), but for an
* npm-managed manifest it is the 3-component truncation — otherwise a
* correctly-synced `0.1.27` would be read as drift against `0.1.27.0`
* forever, and every classify would return DRIFT.
*/
function classifyState(
current: string,
base: string,
pkgExists: boolean,
pkgVersion: string,
expectedPkg: string = current,
): 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";
if (pkgExists && pkgVersion && pkgVersion !== expectedPkg) return "DRIFT_UNEXPECTED";
return "FRESH";
}
// VERSION already moved past base.
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG";
if (pkgExists && pkgVersion && pkgVersion !== expectedPkg) return "DRIFT_STALE_PKG";
return "ALREADY_BUMPED";
}
@ -137,8 +220,11 @@ function cmdClassify(args: string[], cwd: string): void {
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);
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
const pkg = readPkgVersion(pkgPath);
const lockPath = pkg.exists ? lockPathFor(pkgPath) : "";
const expectedPkg = manifestVersion(current, Boolean(lockPath));
const state = classifyState(current, baseV, pkg.exists, pkg.version, expectedPkg);
process.stdout.write(
JSON.stringify({
state,
@ -146,6 +232,9 @@ function cmdClassify(args: string[], cwd: string): void {
currentVersion: current,
pkgVersion: pkg.version || null,
pkgExists: pkg.exists,
pkgPath: pkg.exists ? relative(cwd, pkgPath) : null,
expectedPkgVersion: pkg.exists ? expectedPkg : null,
lockfile: lockPath ? relative(cwd, lockPath) : null,
}) + "\n",
);
// DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the
@ -160,19 +249,42 @@ function cmdWrite(args: string[], cwd: string): void {
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2);
}
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
const hasPkg = existsSync(pkgPath);
const lockPath = hasPkg ? lockPathFor(pkgPath) : "";
writeFileSync(versionPath, version + "\n");
if (existsSync(join(cwd, "package.json"))) {
if (hasPkg) {
const manifestV = manifestVersion(version!, Boolean(lockPath));
try {
writePkgVersion(cwd, version!);
writePkgVersion(pkgPath, manifestV);
} catch {
fail(
"failed to update package.json. VERSION was written but package.json is now stale. " +
`failed to update ${relative(cwd, pkgPath)}. VERSION was written but the manifest is now stale. ` +
"Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.",
3,
);
}
if (lockPath) {
try {
writeLockVersion(lockPath, manifestV);
} catch {
fail(
`failed to update ${relative(cwd, lockPath)}. VERSION and the manifest were written but ` +
"the lockfile is now stale. Run `npm install --package-lock-only` in its directory.",
3,
);
}
}
}
process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n");
process.stdout.write(
JSON.stringify({
wrote: version,
packageJson: hasPkg,
packageJsonPath: hasPkg ? relative(cwd, pkgPath) : null,
packageJsonVersion: hasPkg ? manifestVersion(version!, Boolean(lockPath)) : null,
lockfile: lockPath ? relative(cwd, lockPath) : null,
}) + "\n",
);
}
function cmdRepair(args: string[], cwd: string): void {
@ -185,15 +297,32 @@ function cmdRepair(args: string[], cwd: string): void {
2,
);
}
if (!existsSync(join(cwd, "package.json"))) {
fail("repair: no package.json to sync.", 2);
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
if (!existsSync(pkgPath)) {
fail(`repair: no package.json to sync (looked at ${relative(cwd, pkgPath)}).`, 2);
}
const lockPath = lockPathFor(pkgPath);
const manifestV = manifestVersion(current, Boolean(lockPath));
try {
writePkgVersion(cwd, current);
writePkgVersion(pkgPath, manifestV);
} catch {
fail("drift repair failed — could not update package.json.", 3);
fail(`drift repair failed — could not update ${relative(cwd, pkgPath)}.`, 3);
}
process.stdout.write(JSON.stringify({ repaired: current }) + "\n");
if (lockPath) {
try {
writeLockVersion(lockPath, manifestV);
} catch {
fail(`drift repair failed — could not update ${relative(cwd, lockPath)}.`, 3);
}
}
process.stdout.write(
JSON.stringify({
repaired: current,
packageJsonPath: relative(cwd, pkgPath),
packageJsonVersion: manifestV,
lockfile: lockPath ? relative(cwd, lockPath) : null,
}) + "\n",
);
}
// Exported for unit tests (pure logic, no I/O).

View File

@ -1104,7 +1104,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash

View File

@ -201,7 +201,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash

View File

@ -1104,7 +1104,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash

View File

@ -2288,7 +2288,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash

View File

@ -2704,7 +2704,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash

View File

@ -54,7 +54,10 @@ describe('write (FRESH bump)', () => {
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(JSON.parse(out)).toEqual({
wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json',
packageJsonVersion: '1.1.0.0', lockfile: null,
});
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');
@ -72,7 +75,10 @@ describe('write (FRESH bump)', () => {
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(JSON.parse(out)).toEqual({
wrote: '0.2.0.0', packageJson: false, packageJsonPath: null,
packageJsonVersion: null, lockfile: null,
});
expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0');
fs.rmSync(d2, { recursive: true, force: true });
});
@ -86,7 +92,10 @@ describe('repair (DRIFT_STALE_PKG)', () => {
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(out)).toEqual({
repaired: '2.0.0.0', packageJsonPath: 'package.json',
packageJsonVersion: '2.0.0.0', lockfile: null,
});
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
});
@ -131,3 +140,113 @@ describe('classify (idempotency over a real git base)', () => {
expect(parsed.currentVersion).toBe('1.1.0.0');
});
});
describe('subdirectory manifest (no root package.json)', () => {
/**
* The layout this tool used to silently no-op on: the only Node package
* lives in web/, so join(cwd, "package.json") missed it, classify said
* pkgExists:false, and write touched VERSION alone — leaving the manifest
* to be bumped by hand every release.
*/
const mk = (): string => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-subdir-'));
fs.mkdirSync(path.join(d, 'web'));
fs.mkdirSync(path.join(d, '.gstack'));
fs.writeFileSync(path.join(d, '.gstack', 'package-json-path'), 'web/package.json\n');
fs.writeFileSync(path.join(d, 'VERSION'), '0.1.0.0\n');
return d;
};
test('write finds a pinned manifest and bumps it', () => {
const d = mk();
fs.writeFileSync(path.join(d, 'web', 'package.json'),
JSON.stringify({ name: 'w', version: '0.1.0.0' }, null, 2) + '\n');
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d }).toString());
expect(out.packageJson).toBe(true);
expect(out.packageJsonPath).toBe('web/package.json');
expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.2.0.0');
fs.rmSync(d, { recursive: true, force: true });
});
test('--package-json-path overrides the pin', () => {
const d = mk();
fs.mkdirSync(path.join(d, 'app'));
fs.writeFileSync(path.join(d, 'web', 'package.json'), JSON.stringify({ version: '0.1.0.0' }, null, 2) + '\n');
fs.writeFileSync(path.join(d, 'app', 'package.json'), JSON.stringify({ version: '0.1.0.0' }, null, 2) + '\n');
const out = JSON.parse(execFileSync('bun',
[BIN, 'write', '--version', '0.3.0.0', '--package-json-path', 'app/package.json'], { cwd: d }).toString());
expect(out.packageJsonPath).toBe('app/package.json');
expect(JSON.parse(fs.readFileSync(path.join(d, 'app', 'package.json'), 'utf-8')).version).toBe('0.3.0.0');
// the pinned one is untouched
expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.0.0');
fs.rmSync(d, { recursive: true, force: true });
});
});
describe('npm-managed manifest (lockfile present)', () => {
/**
* VERSION is 4-digit; npm semver is 3-component and rejects a fourth. A
* package-lock.json beside the manifest proves npm actually manages it, so
* the MICRO is dropped there and the lockfile's TWO version fields are
* mirrored. Writing 4 digits into a real npm package breaks `npm ci`.
*/
const mk = (pkgV: string, lockV: string): string => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-npm-'));
fs.mkdirSync(path.join(d, 'web'));
fs.mkdirSync(path.join(d, '.gstack'));
fs.writeFileSync(path.join(d, '.gstack', 'package-json-path'), 'web/package.json\n');
fs.writeFileSync(path.join(d, 'VERSION'), '0.1.25.0\n');
fs.writeFileSync(path.join(d, 'web', 'package.json'),
JSON.stringify({ name: 'w', version: pkgV }, null, 2) + '\n');
fs.writeFileSync(path.join(d, 'web', 'package-lock.json'),
JSON.stringify({ name: 'w', version: lockV, lockfileVersion: 3,
packages: { '': { name: 'w', version: lockV }, 'node_modules/x': { version: '1.0.0' } } }, null, 2) + '\n');
return d;
};
test('write drops the MICRO and syncs BOTH lockfile version fields', () => {
const d = mk('0.1.25', '0.1.25');
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.1.26.0'], { cwd: d }).toString());
expect(out.wrote).toBe('0.1.26.0');
expect(out.packageJsonVersion).toBe('0.1.26');
expect(out.lockfile).toBe('web/package-lock.json');
expect(fs.readFileSync(path.join(d, 'VERSION'), 'utf-8').trim()).toBe('0.1.26.0');
expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.26');
const lock = JSON.parse(fs.readFileSync(path.join(d, 'web', 'package-lock.json'), 'utf-8'));
expect(lock.version).toBe('0.1.26');
expect(lock.packages[''].version).toBe('0.1.26');
expect(lock.packages['node_modules/x'].version).toBe('1.0.0'); // deps untouched
fs.rmSync(d, { recursive: true, force: true });
});
test('a correctly-synced 3-component manifest is NOT read as drift', () => {
// Without the truncation-aware comparison, 0.1.25 vs 0.1.25.0 reads as
// DRIFT forever and every classify returns a false positive.
const d = mk('0.1.25', '0.1.25');
expect(classifyState('0.1.25.0', '0.1.24.0', true, '0.1.25', '0.1.25')).toBe('ALREADY_BUMPED');
fs.rmSync(d, { recursive: true, force: true });
});
test('repair syncs manifest + lockfile to the 3-component form', () => {
const d = mk('0.1.19', '0.1.19');
const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString());
expect(out.repaired).toBe('0.1.25.0');
expect(out.packageJsonVersion).toBe('0.1.25');
expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.25');
const lock = JSON.parse(fs.readFileSync(path.join(d, 'web', 'package-lock.json'), 'utf-8'));
expect(lock.version).toBe('0.1.25');
expect(lock.packages[''].version).toBe('0.1.25');
fs.rmSync(d, { recursive: true, force: true });
});
test('no lockfile keeps the historical 4-digit mirror (gstack itself)', () => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-nolock-'));
fs.writeFileSync(path.join(d, 'VERSION'), '1.60.0.0\n');
fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ version: '1.60.0.0' }, null, 2) + '\n');
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.60.1.0'], { cwd: d }).toString());
expect(out.packageJsonVersion).toBe('1.60.1.0');
expect(out.lockfile).toBeNull();
expect(JSON.parse(fs.readFileSync(path.join(d, 'package.json'), 'utf-8')).version).toBe('1.60.1.0');
fs.rmSync(d, { recursive: true, force: true });
});
});