diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 4af78097b..6f8f486eb 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -30,15 +30,29 @@ // 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 +// Contract: classify NEVER writes. write/repair mutate VERSION + the manifest // + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present) // 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 (#2531). +// +// npm semver (decision 11, v1.67 fix-wave plan): VERSION is the 4-digit +// MAJOR.MINOR.PATCH.MICRO source of truth; npm rejects a fourth component, +// so the manifest and its lockfiles carry the npm-valid 3-digit translation +// (1.67.0.0 → 1.67.0). classify judges drift against the translated form +// (accepting the pre-v1.67 1:1 mirror as in-sync until the next write). import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; -import { join } from "node:path"; -import { extractVersion, isJsonVersionPath, setVersionInJson } from "../lib/version-source"; +import { dirname, join, relative } from "node:path"; +import { extractVersion, isJsonVersionPath, npmVersion, setVersionInJson } from "../lib/version-source"; // 3- or 4-digit (#2501). gstack's own VERSION stays 4-digit MAJOR.MINOR.PATCH. // MICRO and stays the source of truth, but a repo whose pinned version source @@ -81,9 +95,27 @@ function readVersionFile(p: string, versionRel = "VERSION"): string { } } +/** + * Resolve the manifest path: --package-json-path, else + * .gstack/package-json-path, else "package.json" (#2531, 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").split("\n")[0]?.trim() ?? ""; + if (p) return join(cwd, p); + } + return join(cwd, "package.json"); +} + /** 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 { @@ -95,14 +127,13 @@ 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; @@ -157,15 +188,31 @@ 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 — + * the npm-valid 3-digit translation (decision 11: npm rejects a fourth + * component, so a correctly-synced `1.67.0` must not read as drift against + * `1.67.0.0` forever). The historical 1:1 mirror (pre-v1.67 installs whose + * package.json still carries the 4-digit form) is also accepted as in-sync; + * write/repair migrate those to the translated form on the next release. + */ +function classifyState( + current: string, + base: string, + pkgExists: boolean, + pkgVersion: string, + expectedPkg: string = current, +): State { + const pkgAgrees = + !pkgExists || !pkgVersion || pkgVersion === expectedPkg || pkgVersion === current; 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 (!pkgAgrees) return "DRIFT_UNEXPECTED"; return "FRESH"; } // VERSION already moved past base. - if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG"; + if (!pkgAgrees) return "DRIFT_STALE_PKG"; return "ALREADY_BUMPED"; } @@ -181,10 +228,17 @@ function cmdClassify(args: string[], cwd: string): void { // arise — they are the same file. Reporting it as its own pkg keeps DRIFT_* // out of the classification instead of inventing a disagreement between a // file and itself. - const pkg = isJsonVersionPath(versionRel) + const jsonSource = isJsonVersionPath(versionRel); + const pkgPath = jsonSource ? versionPath : resolvePkgPath(cwd, argVal(args, "--package-json-path")); + const pkg = jsonSource ? { exists: existsSync(versionPath), version: current === DEFAULT ? "" : current } - : readPkgVersion(cwd); - const state = classifyState(current, baseV, pkg.exists, pkg.version); + : readPkgVersion(pkgPath); + // Decision 11: the manifest carries the npm-valid 3-digit translation of + // the 4-digit VERSION; drift is judged against the translated form. A + // JSON version-path is its own source of truth, so its expected form is + // the version itself. + const expectedPkg = jsonSource ? current : npmVersion(current); + const state = classifyState(current, baseV, pkg.exists, pkg.version, expectedPkg); process.stdout.write( JSON.stringify({ state, @@ -192,6 +246,8 @@ 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, }) + "\n", ); // DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the @@ -217,24 +273,41 @@ function cmdWrite(args: string[], cwd: string): void { if (!existsSync(versionPath)) { fail(`write: ${versionRel} does not exist. Check --version-path / .gstack/version-path.`, 2); } + let lockSynced: string[] = []; try { writeFileSync(versionPath, setVersionInJson(readFileSync(versionPath, "utf-8"), version!)); + // The pinned manifest's OWN lockfiles (beside it) stay in step too. + lockSynced = syncNpmLockfiles(dirname(versionPath), version!); } catch { fail(`write: failed to update ${versionRel} (is it valid JSON?).`, 3); } - process.stdout.write(JSON.stringify({ wrote: version, versionPath: versionRel, packageJson: true }) + "\n"); + process.stdout.write( + JSON.stringify({ + wrote: version, + versionPath: versionRel, + packageJson: true, + packageLock: lockSynced.length > 0, + }) + "\n", + ); return; } + const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path")); + const hasPkg = existsSync(pkgPath); writeFileSync(versionPath, version + "\n"); let lockSynced: string[] = []; - if (existsSync(join(cwd, "package.json"))) { + // Decision 11: the manifest (and its lockfiles) carry the npm-valid + // 3-digit translation — npm rejects a fourth component, so mirroring the + // raw 4-digit form breaks `npm ci` in any repo npm actually manages. + // VERSION keeps the full 4-digit form; it stays the source of truth. + const manifestV = npmVersion(version!); + if (hasPkg) { try { - writePkgVersion(cwd, version!); - lockSynced = syncNpmLockfiles(cwd, version!); + writePkgVersion(pkgPath, manifestV); + lockSynced = syncNpmLockfiles(dirname(pkgPath), manifestV); } catch { fail( - "failed to update package.json/npm lockfiles. VERSION was written but the npm " + + `failed to update ${relative(cwd, pkgPath)}/npm lockfiles. VERSION was written but the npm ` + "manifests are now stale. Re-run — classify will report DRIFT_STALE_PKG and repair will sync them.", 3, ); @@ -243,7 +316,9 @@ function cmdWrite(args: string[], cwd: string): void { process.stdout.write( JSON.stringify({ wrote: version, - packageJson: existsSync(join(cwd, "package.json")), + packageJson: hasPkg, + packageJsonPath: hasPkg ? relative(cwd, pkgPath) : null, + packageJsonVersion: hasPkg ? manifestV : null, packageLock: lockSynced.length > 0, }) + "\n", ); @@ -269,16 +344,26 @@ 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); } + // Decision 11: repair syncs the manifest + lockfiles to the npm-valid + // 3-digit translation of the current VERSION. + const manifestV = npmVersion(current); try { - writePkgVersion(cwd, current); - syncNpmLockfiles(cwd, current); + writePkgVersion(pkgPath, manifestV); + syncNpmLockfiles(dirname(pkgPath), manifestV); } catch { fail("drift repair failed — could not update package.json/npm lockfiles.", 3); } - process.stdout.write(JSON.stringify({ repaired: current }) + "\n"); + process.stdout.write( + JSON.stringify({ + repaired: current, + packageJsonPath: relative(cwd, pkgPath), + packageJsonVersion: manifestV, + }) + "\n", + ); } // Exported for unit tests (pure logic, no I/O). diff --git a/lib/version-source.ts b/lib/version-source.ts index 2d61fa0f5..17a745131 100644 --- a/lib/version-source.ts +++ b/lib/version-source.ts @@ -85,6 +85,18 @@ export function bumpWasCoerced(level: Bump, width: VersionWidth): boolean { return width === 3 && level === "micro"; } +/** + * The npm-valid form of a gstack version. npm's semver is 3-component and + * rejects a fourth, so the 4-digit MAJOR.MINOR.PATCH.MICRO truncates to + * MAJOR.MINOR.PATCH; 3-digit versions pass through unchanged. Per the + * version-tooling end-state spec (v1.67 fix-wave plan, decision 11): the + * manifest mirror always carries this form, and VERSION stays the 4-digit + * source of truth. + */ +export function npmVersion(version: string): string { + return version.trim().split(".").slice(0, 3).join("."); +} + /** A version-path pointing at a .json is read as JSON, not as raw text. */ export function isJsonVersionPath(versionPath: string): boolean { return /\.json$/i.test(versionPath.trim()); diff --git a/ship/SKILL.md b/ship/SKILL.md index 3ce2f8641..86340ae65 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -1108,7 +1108,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 version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. 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 the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. 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..1b5ae29dd 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 version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. 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 the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. 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 3ce2f8641..86340ae65 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -1108,7 +1108,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 version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. 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 the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. 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 8e4def16c..98b6a9ed9 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -2292,7 +2292,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 version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. 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 the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. 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 d42534707..94533d246 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -2708,7 +2708,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 version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. 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 the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. 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 4cc4ebc99..d76058581 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -62,10 +62,15 @@ 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, packageLock: false }); + expect(JSON.parse(out)).toEqual({ + wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json', + packageJsonVersion: '1.1.0', 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'); + // Decision 11: the manifest carries the npm-valid 3-digit translation; + // VERSION keeps the 4-digit form and stays the source of truth. + expect(pkg.version).toBe('1.1.0'); expect(pkg.scripts).toEqual({ t: 'y' }); // untouched }); @@ -80,7 +85,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, packageLock: false }); + expect(JSON.parse(out)).toEqual({ + wrote: '0.2.0.0', packageJson: false, packageJsonPath: null, + packageJsonVersion: null, packageLock: false, + }); expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0'); fs.rmSync(d2, { recursive: true, force: true }); }); @@ -94,8 +102,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(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0.0'); + expect(JSON.parse(out)).toEqual({ + repaired: '2.0.0.0', packageJsonPath: 'package.json', packageJsonVersion: '2.0.0', + }); + expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0'); expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('2.0.0.0'); // unchanged }); @@ -119,46 +129,49 @@ describe('write/repair sync npm lockfiles (both version fields, #2567)', () => { 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')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), lock('1.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 }); + expect(JSON.parse(out)).toEqual({ + wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json', + packageJsonVersion: '1.1.0', 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.version).toBe('1.1.0'); + expect(l.packages[''].version).toBe('1.1.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')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0' }, null, 2) + '\n'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), lock('1.9.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'); + expect(l.version).toBe('2.0.0'); + expect(l.packages[''].version).toBe('2.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'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '2.9.0' }, null, 2) + '\n'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify({ name: 'x', version: '2.9.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.version).toBe('3.0.0'); expect(l.packages).toBeUndefined(); }); test('npm-shrinkwrap.json is synced too when present (never created)', () => { const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-shrink-')); fs.writeFileSync(path.join(d2, 'VERSION'), '1.0.0.0\n'); - fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0' }, null, 2) + '\n'); - fs.writeFileSync(path.join(d2, 'npm-shrinkwrap.json'), lock('1.0.0.0').replace('package-lock', 'npm-shrinkwrap')); + fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n'); + fs.writeFileSync(path.join(d2, 'npm-shrinkwrap.json'), lock('1.0.0').replace('package-lock', 'npm-shrinkwrap')); const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d2 }).toString(); expect(JSON.parse(out).packageLock).toBe(true); const l = JSON.parse(fs.readFileSync(path.join(d2, 'npm-shrinkwrap.json'), 'utf-8')); - expect(l.version).toBe('1.1.0.0'); - expect(l.packages[''].version).toBe('1.1.0.0'); + expect(l.version).toBe('1.1.0'); + expect(l.packages[''].version).toBe('1.1.0'); // No package-lock.json invented alongside it. expect(fs.existsSync(path.join(d2, 'package-lock.json'))).toBe(false); fs.rmSync(d2, { recursive: true, force: true }); @@ -253,7 +266,7 @@ describe('package.json as the version source (monorepo, 3-digit, #2501)', () => test('write updates the package.json in place and creates no VERSION file', () => { const out = execFileSync('bun', [BIN, 'write', '--version', '0.99.3', '--version-path', pkgRel], { cwd: dir }).toString(); - expect(JSON.parse(out)).toEqual({ wrote: '0.99.3', versionPath: pkgRel, packageJson: true }); + expect(JSON.parse(out)).toEqual({ wrote: '0.99.3', versionPath: pkgRel, packageJson: true, packageLock: false }); const pkg = JSON.parse(fs.readFileSync(pkgAbs, 'utf-8')); expect(pkg.version).toBe('0.99.3'); expect(pkg.scripts).toEqual({ dev: 'next dev' }); // rest of the file untouched @@ -282,3 +295,101 @@ describe('package.json as the version source (monorepo, 3-digit, #2501)', () => expect(code).toBe(2); }); }); + +describe('subdirectory manifest (no root package.json, #2531)', () => { + /** + * 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 (npm-valid form)', () => { + const d = mk(); + fs.writeFileSync(path.join(d, 'web', 'package.json'), + JSON.stringify({ name: 'w', version: '0.1.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(out.packageJsonVersion).toBe('0.2.0'); + expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.2.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' }, null, 2) + '\n'); + fs.writeFileSync(path.join(d, 'app', 'package.json'), JSON.stringify({ version: '0.1.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'); + // the pinned one is untouched + expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.0'); + fs.rmSync(d, { recursive: true, force: true }); + }); + + test('classify reads the pinned manifest and judges drift on the translated form', () => { + const d = mk(); + fs.writeFileSync(path.join(d, 'web', 'package.json'), + JSON.stringify({ name: 'w', version: '0.1.0' }, null, 2) + '\n'); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: d }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: d }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: d }); + execFileSync('git', ['add', '-A'], { cwd: d }); + execFileSync('git', ['commit', '-qm', 'base'], { cwd: d }); + const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: d }).toString().trim(); + fs.mkdirSync(path.join(d, '.git', 'refs', 'remotes', 'origin'), { recursive: true }); + fs.writeFileSync(path.join(d, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n'); + + const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString()); + // 0.1.0 IS the npm-valid translation of 0.1.0.0 — in sync, no drift. + expect(out.state).toBe('FRESH'); + expect(out.pkgExists).toBe(true); + expect(out.pkgPath).toBe('web/package.json'); + expect(out.expectedPkgVersion).toBe('0.1.0'); + fs.rmSync(d, { recursive: true, force: true }); + }); + + test('repair syncs the pinned manifest to the npm-valid form', () => { + const d = mk(); + fs.writeFileSync(path.join(d, 'web', 'package.json'), + JSON.stringify({ name: 'w', version: '0.0.9' }, null, 2) + '\n'); + const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString()); + expect(out).toEqual({ repaired: '0.1.0.0', packageJsonPath: 'web/package.json', packageJsonVersion: '0.1.0' }); + expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.0'); + fs.rmSync(d, { recursive: true, force: true }); + }); +}); + +describe('npm-valid drift contract (decision 11)', () => { + test('a correctly-synced 3-component manifest is NOT read as drift', () => { + // Without the translation-aware comparison, 0.1.25 vs 0.1.25.0 reads as + // DRIFT forever and every classify returns a false positive. + expect(classifyState('0.1.25.0', '0.1.24.0', true, '0.1.25', '0.1.25')).toBe('ALREADY_BUMPED'); + expect(classifyState('0.1.25.0', '0.1.25.0', true, '0.1.25', '0.1.25')).toBe('FRESH'); + }); + + test('the pre-v1.67 1:1 four-digit mirror is grandfathered as in-sync', () => { + // Existing installs still carry package.json 1.66.0.0 next to VERSION + // 1.66.0.0. Flagging that as DRIFT_UNEXPECTED would hard-stop /ship on + // every repo on upgrade day; the next write migrates the manifest to the + // translated form instead. + expect(classifyState('1.66.0.0', '1.65.0.0', true, '1.66.0.0', '1.66.0')).toBe('ALREADY_BUMPED'); + expect(classifyState('1.66.0.0', '1.66.0.0', true, '1.66.0.0', '1.66.0')).toBe('FRESH'); + }); + + test('a genuinely diverged manifest still reads as drift', () => { + expect(classifyState('1.67.0.0', '1.66.0.0', true, '1.66.0', '1.67.0')).toBe('DRIFT_STALE_PKG'); + expect(classifyState('1.66.0.0', '1.66.0.0', true, '9.9.9', '1.66.0')).toBe('DRIFT_UNEXPECTED'); + }); +});