From b672aa68d1352e1aeab1991fa409eedcb294be51 Mon Sep 17 00:00:00 2001 From: ortonom <3261546+ortonom@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:34:44 -0500 Subject: [PATCH] fix(version-bump): write/repair sync package-lock.json alongside package.json The write/repair contract mutates VERSION + package.json and nothing else, but package-lock.json carries the version twice (top-level and packages[""].version in lockfileVersion >= 2). Every /ship bump therefore leaves the lockfile stale; the tree goes dirty on the next npm install and downstream clean-tree asserts reject the work far from the cause. One consumer repo patched this at the symptom six times before tracing it here. write and repair now also sync both lockfile fields when the file exists, under the same exit-3 half-write semantics as the package.json write. Dependency entries are untouched; lockfileVersion 1 syncs top-level only; no lockfile is a no-op. Co-Authored-By: Claude Fable 5 --- bin/gstack-version-bump | 39 ++++++++++++++++++++++----- test/gstack-version-bump.test.ts | 46 ++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 298fab17d..04cd663d5 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -31,8 +31,8 @@ // 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. +// + package-lock.json (when present) 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"; @@ -101,6 +101,25 @@ function writePkgVersion(cwd: string, version: string): void { writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n"); } +/** + * package-lock.json carries the version twice (top-level `version` and, in + * lockfileVersion >= 2, `packages[""].version`). Leaving either stale after a + * bump dirties the tree on the next `npm install`, which downstream clean-tree + * asserts reject far from the cause. No-op when the lockfile is absent. + */ +function writeLockVersion(cwd: string, version: string): void { + const lockPath = join(cwd, "package-lock.json"); + if (!existsSync(lockPath)) return; + const raw = readFileSync(lockPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + parsed.version = version; + const packages = parsed.packages as Record> | undefined; + if (packages && typeof packages[""] === "object" && packages[""] !== null) { + 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 { @@ -164,15 +183,22 @@ function cmdWrite(args: string[], cwd: string): void { if (existsSync(join(cwd, "package.json"))) { try { writePkgVersion(cwd, version!); + writeLockVersion(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.", + "failed to update package.json/package-lock.json. VERSION was written but the npm " + + "manifests are now stale. Re-run — classify will report DRIFT_STALE_PKG and repair will sync them.", 3, ); } } - process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n"); + process.stdout.write( + JSON.stringify({ + wrote: version, + packageJson: existsSync(join(cwd, "package.json")), + packageLock: existsSync(join(cwd, "package-lock.json")), + }) + "\n", + ); } function cmdRepair(args: string[], cwd: string): void { @@ -190,8 +216,9 @@ function cmdRepair(args: string[], cwd: string): void { } try { writePkgVersion(cwd, current); + writeLockVersion(cwd, current); } catch { - fail("drift repair failed — could not update package.json.", 3); + fail("drift repair failed — could not update package.json/package-lock.json.", 3); } process.stdout.write(JSON.stringify({ repaired: current }) + "\n"); } diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index ffcecd1a7..de83c31d5 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -54,7 +54,7 @@ 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, packageLock: false }); 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 +72,7 @@ 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, packageLock: false }); expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0'); fs.rmSync(d2, { recursive: true, force: true }); }); @@ -100,6 +100,48 @@ describe('repair (DRIFT_STALE_PKG)', () => { }); }); +describe('write/repair sync package-lock.json (both version fields)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-lock-')); + afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + const lock = (v: string) => JSON.stringify({ + name: 'x', version: v, lockfileVersion: 3, + packages: { '': { name: 'x', version: v }, 'node_modules/a': { version: '9.9.9' } }, + }, null, 2) + '\n'; + + test('write updates top-level version and packages[""].version, leaves deps alone', () => { + 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'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), lock('1.0.0.0')); + 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, packageLock: true }); + const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8')); + expect(l.version).toBe('1.1.0.0'); + expect(l.packages[''].version).toBe('1.1.0.0'); + expect(l.packages['node_modules/a'].version).toBe('9.9.9'); // untouched + }); + + test('repair heals a stale lockfile alongside package.json', () => { + 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'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), lock('1.9.0.0')); + execFileSync('bun', [BIN, 'repair'], { cwd: dir }); + const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8')); + expect(l.version).toBe('2.0.0.0'); + expect(l.packages[''].version).toBe('2.0.0.0'); + }); + + test('lockfileVersion 1 (no packages map) syncs top-level only, no crash', () => { + fs.writeFileSync(path.join(dir, 'VERSION'), '3.0.0.0\n'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '2.9.0.0' }, null, 2) + '\n'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify({ name: 'x', version: '2.9.0.0', lockfileVersion: 1 }, null, 2) + '\n'); + execFileSync('bun', [BIN, 'repair'], { cwd: dir }); + const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8')); + expect(l.version).toBe('3.0.0.0'); + expect(l.packages).toBeUndefined(); + }); +}); + 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 */ } });