Files
gstack/lib/version-source.ts
T
7b5fdab8cb feat: subdirectory manifests + npm-valid version mirror (#2531)
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 <carrdenn3@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:32 -07:00

131 lines
5.7 KiB
TypeScript

// version-source — where a repo's version lives, and how wide it is.
//
// gstack's native shape is a plain-text VERSION file at the repo root holding a
// 4-digit MAJOR.MINOR.PATCH.MICRO — and for gstack itself that file STAYS the
// source of truth (decision pinned in the v1.67 fix-wave plan: package.json is
// a translated mirror, never the authority). This module exists for the two
// real-world shapes that did not fit and both failed CLOSED in a way that
// silently disabled /ship's version tooling (#2501):
//
// 1. The version's home is a package.json — often not at the root (a monorepo
// whose frontend/package.json is the single source of truth because the
// build injects it). The --version-path / .gstack/version-path pin already
// let you point anywhere, but the readers treated the target as raw text,
// so a JSON file was whitespace-stripped into `{"name":"frontend",...` and
// every version read came back as the 0.0.0.0 fallback — including rival
// PRs' claims fetched through the GitHub Contents API, which were then
// dropped as "malformed".
// 2. The version is 3-digit semver. parseVersion() required exactly four
// components, so gstack-next-version exited 2 ("could not parse base
// version") on every invocation — and that CLI *is* the queue-collision
// check, so /ship fell through to its documented "offline" path of naive
// local arithmetic. Two branches cut from the same base then pick the same
// version, and git merges that without a conflict because both sides set
// one line to identical text. The duplicate slot ships silently.
//
// Both are handled here rather than in each CLI so the two agree by construction.
//
// Detection is by shape, not configuration: a version-path ending in .json is
// read as JSON (.version), anything else as trimmed text; a version string with
// three components stays three components through bumping and formatting. A
// repo with a root VERSION file and 4-digit versions sees no behaviour change.
//
// Re-derived from PR #2501 by @YiftahR.
export type Version = [number, number, number, number];
export type VersionWidth = 3 | 4;
export type Bump = "major" | "minor" | "patch" | "micro";
/** Parse 3- or 4-component versions. 3-digit pads to [a,b,c,0] so comparison stays uniform. */
export function parseVersion(s: string): Version | null {
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$/);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4] ?? 0)];
}
/** How many components the string actually had — what to format back out as. */
export function versionWidth(s: string): VersionWidth {
return /^\d+\.\d+\.\d+\.\d+$/.test(s.trim()) ? 4 : 3;
}
export function fmtVersion(v: Version, width: VersionWidth = 4): string {
return v.slice(0, width).join(".");
}
export function cmpVersion(a: Version, b: Version): number {
for (let i = 0; i < 4; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}
/**
* Bump one level. In a 3-digit repo there is no MICRO component to move, so
* `micro` is carried out as a PATCH: /ship auto-picks MICRO by default, and
* erroring there would make it unusable in every 3-digit repo — a silent no-op
* would be worse still, since the caller would then write back the version it
* started with and claim a taken slot.
*/
export function bumpVersion(v: Version, level: Bump, width: VersionWidth = 4): Version {
const effective: Bump = width === 3 && level === "micro" ? "patch" : level;
switch (effective) {
case "major":
return [v[0] + 1, 0, 0, 0];
case "minor":
return [v[0], v[1] + 1, 0, 0];
case "patch":
return [v[0], v[1], v[2] + 1, 0];
case "micro":
return [v[0], v[1], v[2], v[3] + 1];
}
}
/** True when the effective bump differs from the one asked for (so callers can say so). */
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());
}
/**
* Pull the version out of whatever the version-path resolves to. `text` is the
* file's contents from anywhere — local read, `git show`, or a base64-decoded
* API response — so every reader agrees on interpretation. Returns "" when
* there is no usable version, which callers map to their own fallback.
*/
export function extractVersion(text: string, versionPath: string): string {
if (!isJsonVersionPath(versionPath)) return text.replace(/[\r\n\s]/g, "");
try {
const parsed = JSON.parse(text) as { version?: unknown };
return typeof parsed?.version === "string" ? parsed.version.trim() : "";
} catch {
return "";
}
}
/**
* Write a version back into a JSON file, preserving the rest of it. Deliberately
* key-order-preserving (JSON.parse/stringify keeps insertion order) and 2-space
* indented with a trailing newline, matching what package managers write.
*/
export function setVersionInJson(raw: string, version: string): string {
const parsed = JSON.parse(raw) as Record<string, unknown>;
parsed.version = version;
return JSON.stringify(parsed, null, 2) + "\n";
}