From 665f979d57c1ef7b364d6e85c01e989b808a7426 Mon Sep 17 00:00:00 2001 From: Carrington Dennis Date: Wed, 12 Aug 2026 11:45:30 -0400 Subject: [PATCH] fix(ship): version-bump skipped subdirectory manifests and wrote npm-invalid versions Two ways `gstack-version-bump` left a release half-done, both silent. 1. It only ever looked at ./package.json. A repo whose only Node package lives in a subdirectory (web/, app/, frontend/) has no root manifest, so classify reported pkgExists:false and every bump wrote VERSION alone -- leaving the manifest to be edited by hand, which is the exact drift this tool exists to prevent, in the one layout where it silently did nothing. The path now resolves --package-json-path -> .gstack/package-json-path -> ./package.json, so a subdirectory package is covered by a one-line pin. 2. VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO and npm's semver is 3-component: npm rejects the fourth. Mirroring VERSION 1:1 into an npm-managed manifest writes a version npm will not install. When a package-lock.json sits beside the manifest -- proof npm actually manages it -- the MICRO is now dropped (0.1.26.0 -> 0.1.26) and the lockfile's two version fields are mirrored too (root and packages[""], which npm keeps in step and 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. Without a lockfile nothing validates the field, so the historical 1:1 mirror is preserved -- gstack's own package.json carries 1.60.1.0 and keeps doing so. classifyState needed an expectedPkg parameter for (2): it defaults to VERSION, but for an npm-managed manifest it is the 3-component truncation. Otherwise a correctly-synced 0.1.27 reads as drift against 0.1.27.0 forever and every classify returns DRIFT. Step 12 of the ship skill documents the new behaviour; the three ship-skill golden fixtures are updated to match the regenerated hosts. 7 new tests covering both sides, including the negatives: the pin, the override, both lockfile fields, the correctly-synced-is-not-drift case, repair, and the no-lockfile 4-digit path that must not change. Co-Authored-By: Claude Opus 5 --- bin/gstack-version-bump | 175 ++++++++++++++++++--- ship/SKILL.md | 2 +- ship/SKILL.md.tmpl | 2 +- test/fixtures/golden/claude-ship-SKILL.md | 2 +- test/fixtures/golden/codex-ship-SKILL.md | 2 +- test/fixtures/golden/factory-ship-SKILL.md | 2 +- test/gstack-version-bump.test.ts | 125 ++++++++++++++- 7 files changed, 279 insertions(+), 31 deletions(-) diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 298fab17d..e8118031f 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -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

→ .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; 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; + parsed.version = version; + const packages = parsed.packages as Record | 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). diff --git a/ship/SKILL.md b/ship/SKILL.md index 3062cda60..b6c512470 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -1065,7 +1065,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 diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 068ac4fe5..0556b5332 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -187,7 +187,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 diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 3062cda60..b6c512470 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -1065,7 +1065,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 diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 1d9b707bf..ae1e4cf16 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -2207,7 +2207,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 diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 40ad2e3f3..e4a2b5d35 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -2613,7 +2613,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 diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index ffcecd1a7..ee22ae945 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -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 }); + }); +});