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');
+ });
+});