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 1446dd74b..7bc429f1f 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -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 diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index a01296688..3042c072d 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -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 diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 1446dd74b..7bc429f1f 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -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 diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 642e15bc9..42a3a0df9 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -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 diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 14d070b52..28bbbe966 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -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 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 }); + }); +});