From 7b5fdab8cb1c879597faba283c378f44aaa8cae9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:31:48 -0700 Subject: [PATCH] feat: subdirectory manifests + npm-valid version mirror (#2531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in gstack-version-bump's manifest handling, resolved to the wave plan's version-tooling end-state spec (decision 11): 1. Subdirectory manifests. A repo whose only Node package lives in web/, app/, or frontend/ has no ROOT package.json, so join(cwd, "package.json") reported pkgExists:false and every bump silently wrote VERSION alone — leaving the manifest to be bumped by hand, which is exactly the drift this tool exists to prevent, in the one layout where it silently did nothing. All three subcommands now resolve the manifest as --package-json-path → .gstack/package-json-path → ./package.json (mirroring resolveVersionPath). 2. npm-valid mirror. VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm's semver is 3-component and rejects a fourth, so mirroring the raw form breaks `npm ci` in any repo npm actually manages. The manifest and its lockfiles now carry the npm-valid 3-digit translation (1.67.0.0 → 1.67.0) via npmVersion() in lib/version-source.ts. VERSION stays the 4-digit source of truth. classify judges drift against the TRANSLATED form — a correctly-synced `0.1.25` no longer reads as eternal drift against `0.1.25.0` — and grandfathers the pre-v1.67 1:1 four-digit mirror as in-sync (flagging it DRIFT_UNEXPECTED would hard-stop /ship on every existing repo on upgrade day; the next write migrates the manifest to the translated form). Lockfiles are synced beside the resolved manifest — including beside a pinned JSON version-path — and only when they already exist. classify output gains pkgPath and expectedPkgVersion for observability; write/repair report packageJsonPath + packageJsonVersion. The /ship Step 12 prose (ship/SKILL.md.tmpl) documents the resolution chain and the translation; SKILL.md files regenerated and ship golden fixtures refreshed in this commit. Tests: subdirectory pin + --package-json-path override, translated-form classify (FRESH/ALREADY_BUMPED, no false drift), grandfathered 1:1 mirror, genuine divergence still drifts, repair to the npm-valid form (33 pass in test/gstack-version-bump.test.ts; 526 pass across the five affected files including goldens and parity). Re-derived from PR #2531 by @CarringtonCreative on top of the 3-digit/ JSON version-source work, under decision 11 (which resolves the PR's lockfile-gated translation in favor of an unconditional npm-valid mirror). Co-authored-by: Carrington Dennis Co-Authored-By: Claude Fable 5 --- bin/gstack-version-bump | 135 ++++++++++++++---- lib/version-source.ts | 12 ++ 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 | 155 ++++++++++++++++++--- 8 files changed, 260 insertions(+), 52 deletions(-) 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'); + }); +});