fix(version-bump): missing or empty VERSION no longer repairs a fabricated 0.0.0.0 into package.json

repair now fails with exit 2 when the VERSION file is absent or empty
instead of folding to DEFAULT ("0.0.0.0") — which passed VERSION_RE and
regressed package.json below where it started. classify gains an additive
versionFileExists field so /ship can tell a real 0.0.0.0 from a fabricated
one. Re-derived from PR #2612 under the generated-file screening rule.

Fixes #2600 (repair half; the path-configurability half landed in v1.67 via #2531).
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-17 10:09:48 -07:00
co-authored by Claude Fable 5
parent 9c4de4fe5b
commit f9f0e84add
2 changed files with 156 additions and 0 deletions
+28
View File
@@ -313,6 +313,11 @@ function cmdClassify(args: string[], cwd: string): void {
// the version itself.
const expectedPkg = jsonSource ? current : npmVersion(current);
const state = classifyState(current, baseV, pkg.exists, pkg.version, expectedPkg);
// Surface version-file absence so callers (and /ship) can tell "version is
// genuinely 0.0.0.0" from "we made up 0.0.0.0 because the file is missing"
// (#2600). Without this, the DRIFT_STALE_PKG dispatch on a missing VERSION
// would feed repair a fabricated version that passes the shape check.
const versionFileExists = existsSync(versionPath);
process.stdout.write(
JSON.stringify({
state,
@@ -322,6 +327,7 @@ function cmdClassify(args: string[], cwd: string): void {
pkgExists: pkg.exists,
pkgPath: pkg.exists ? relative(cwd, pkgPath) : null,
expectedPkgVersion: pkg.exists ? expectedPkg : null,
versionFileExists,
}) + "\n",
);
// DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the
@@ -442,7 +448,29 @@ function cmdRepair(args: string[], cwd: string): void {
);
return;
}
// Guard: if the VERSION file does not exist, readVersionFile folds that into
// DEFAULT ("0.0.0.0") — a structurally valid but fabricated version. The
// shape check below (VERSION_RE) would pass it, and we would write 0.0.0
// into package.json, regressing it below where it started (#2600).
if (!existsSync(versionPath)) {
fail(
`VERSION file not found at ${versionRel}. ` +
"Cannot repair package.json without a real version to sync. " +
"Pass --version-path or set .gstack/version-path if the file lives elsewhere.",
2,
);
}
const current = readVersionFile(versionPath, versionRel);
// Guard against readVersionFile folding "file exists but is empty / unparsable"
// into DEFAULT ("0.0.0.0") — same data-corruption pathway as file-missing (#2600).
// A fabricated version must never propagate into package.json.
if (current === DEFAULT) {
fail(
`VERSION file at ${versionRel} is empty or contains no parsable version. ` +
"Cannot repair package.json with a fabricated version.",
2,
);
}
if (!VERSION_RE.test(current)) {
fail(
`VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH[.MICRO]. ` +