fix(pregate): waive version-stamp-only manifests from the lanes check (GBRA-95) (#3461)

gbrain's release:restamp rewrites its plugin manifests and VERSION; its
base's .gstack/pregate.json declares no lane for them, and pregate reads
policy from the base, so every restamp PR failed lanes with
PREGATE_NO_LANE and could not fix itself (gbrain PRs 7625 and 7606).

lib/pregate/stamp-only.ts defines version-stamp-only per file kind: a JSON
document of the same shape whose every changed leaf is a bare version under
a key named for a version; plist <string> versions under a ...Version...
key; a VERSION file that is one bare version on both sides. lane-selection
applies it only after the import graph, the declarations, the regen
registry and both empty lanes found nothing; lanes prints the waived files
on their own line and --explain the per-file verdict. Anything else (a
changed description, a new key, a dependency pin, an added or deleted
manifest) keeps its PREGATE_NO_LANE.

Tests: pure rows for every rule, and bin fixtures for a stamp-only PR
(passes), a manifest with a changed description (refused), a mixed diff
(stamped files waived, the orphan source file still refused, a mapped file
still runs its test) and an added/deleted manifest.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 12:14:55 +00:00
1 parent 8018ba52fe
commit d62eb914dc
13 files changed
+598 -10

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [1.91.244.0] - 2026-10-26
**A restamp PR no longer fails pregate for the manifests it stamps.** gbrain's `bun run release:restamp` rewrites its plugin manifests and VERSION; its base's `.gstack/pregate.json` declares no lane for them, and pregate reads policy from the base, so every such PR failed `lanes` with `PREGATE_NO_LANE` and could not fix itself (gbrain #7625, #7606).
### Fixed
- **`gstack-pregate` lanes**: a touched `*.json`, `*.plist`, `VERSION` or `VERSION.txt` file whose diff against the merge base changes nothing but its version values is waived as version-stamp-only, on its own line (`version-stamp-only (no lane needed): <files> — only version values changed against the merge base; a manifest whose other content changed still needs a lane`), with the per-file verdict under `--explain`. The rule is exact per kind (`lib/pregate/stamp-only.ts`): JSON objects of the same shape (same own keys in the same order, same array lengths, same leaf types) whose only changed leaf is the document's top-level `version`, a bare version on both sides, with the text differing on that line alone; plist `<string>` versions under `CFBundleShortVersionString` or `CFBundleVersion`; a VERSION file that is one bare version on both sides. Nothing nested qualifies (a dependency pin, a lockfile entry, a catalog), texts are compared byte for byte outside the stamped value (a line-ending change or a BOM is content), and a changed description, a new, removed or reordered key, a floor such as `minHostVersion` or `MinimumOSVersion`, a change the parser would erase, an invalid-UTF-8 or over-1-MiB file, a mode change or symlink, an added, deleted or renamed manifest, a file under a test, fixture, golden, `.gstack/` or workflow tree, or any other file kind is not a stamp and keeps its `PREGATE_NO_LANE`. It applies only after the import graph, the declarations, the regen registry and both empty lanes found nothing, so a manifest a test reads still selects that test. `docs/pregate.md` and the `PREGATE_NO_LANE` troubleshooting entry state the rule.
## [1.91.243.0] - 2026-10-26
**The launch environment reaches every gstack command and child through a sourceable file, and nothing in a skill fence may print the environment (GSTA-104).** During GSTA-96's unattended /ship on #3425 (2026-10-26), the Step 14.5 docs-audit child was told to export the `GSTACK_LAUNCH` assignments and re-typed them as `export $(...)`. The substitution came back empty, the shell ran a bare `export`, and the values of ANTHROPIC_API_KEY, OPENAI_API_KEY and UBICLOUD_API_KEY landed in its tool output. The line itself carries no secret (PATH, GSTACK_SESSION_KIND, GSTACK_STATE_ROOT, GSTACK_EPHEMERAL, GSTACK_CAPY, GSTACK_INSTALL_VERDICT); the hazard was re-typing it. Now the installer writes the assignments as a file callers source, every fence that hands the launch environment to a child says so in one line, a free ratchet keeps environment dumps out of every skill fence and shell helper, and the redaction engine refuses a credential value from the writer's own environment, whatever its shape.
+1 -1
View File
@@ -1 +1 @@
1.91.243.0
1.91.244.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.243.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.244.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+30
View File
@@ -194,6 +194,36 @@ Two stages with separate clocks, both printed:
issue templates, CODEOWNERS and dependabot config). Stylesheets (they import
each other), any other dotfile (`.rspec`, `.husky/` hooks, `.env*`, version
pins), CI workflows and actions, and `.gstack/` still need a lane.
A release manifest the branch only re-stamped is waived as
version-stamp-only (`version-stamp-only (no lane needed): …`): the file's
diff against the merge base changes nothing but its version values. Three
file kinds qualify, each by its own exact rule (lib/pregate/stamp-only.ts).
A JSON file (`*.json`) whose two sides parse to objects of the same shape
(the same own keys in the same order in every object, the same length in
every array, the same type at every leaf), whose only differing leaf is the
document's top-level `version` with a bare version on both sides (`1.2.3`,
`v1.2.3.4`, `1.2.3-rc.1`; never a range), and whose text is line-for-line
identical once that line has its old quoted version replaced by the new
one. A plist whose differing lines are all `<string>` versions under
`<key>CFBundleShortVersionString</key>` or `<key>CFBundleVersion</key>`. A
`VERSION` or `VERSION.txt` file (any case) holding one bare version, with at
most a trailing LF, on both sides. Texts are compared byte for byte outside
the stamped value, so a line-ending change or a BOM is content. Nothing
nested in JSON qualifies, so a dependency pin or range, a lockfile entry, a
catalog or a package named `version` is never a stamp; nor is a changed
description, a new, removed or reordered key, a floor such as
`minHostVersion`, `schemaVersion` or `MinimumOSVersion`, a reflow, a change
the parser would erase (a big integer, a duplicate key), a file that is not
valid UTF-8 or is over 1 MiB, a mode change or a symlink on either side, an
added, deleted or renamed manifest, or any other file kind: each keeps its
`PREGATE_NO_LANE`. The rule runs only after the import graph, the
declarations, the regen registry and both empty lanes found nothing, so a
manifest a test reads still selects that test, and never under a test,
fixture, golden, snapshot, `.gstack/` or CI-workflow tree, whose JSON a test
may compare against by a path the graph cannot see.
`--explain` prints the verdict per file (`version-stamp-only against
<merge-base> (version 1.0.0 → 1.0.1)` or `not version-stamp-only
(<reason>)`).
In a Django project (a manage.py or code importing django, plus Python
code, not a string or comment, that assigns `INSTALLED_APPS` or
`ROOT_URLCONF`, or a config file names `DJANGO_SETTINGS_MODULE`), the files
+14
View File
@@ -3031,6 +3031,20 @@ test. An unmapped source or code file still fails. A file under a test
directory is not this empty lane. Images, fonts and media, and
allowlisted tool config no test names, are the assets-and-tool-config empty lane,
not this error.
A release manifest the branch only re-stamped is not this error either: when a
touched `*.json`, `*.plist`, `VERSION` or `VERSION.txt` file differs from the
merge base in nothing but its version values, `lanes` waives it as
version-stamp-only and prints `version-stamp-only (no lane needed): <files>`.
The exact per-kind rule (the document's top-level `version` in JSON of the
same shape and text, the two bundle version keys in a plist, one bare version
in a VERSION file) is in [docs/pregate.md](pregate.md#gstack-pregate). A
restamp PR on a base whose policy declares no lane for its manifests (gbrain's
`release:restamp` and its plugin manifests) therefore passes without a
policy change. A manifest whose description, keys, key order, nested
versions, line endings or any non-version byte changed, one under a test,
fixture, golden, `.gstack/` or workflow tree, or one added, deleted, renamed
or re-moded, is still this error; `--explain` prints `not version-stamp-only (<reason>)`
for it.
**Fix.** Add a test that names the file, or declare its lane (an explicit `[]`
for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or the user-owned file the message names).
+7 -4
View File
@@ -7,6 +7,9 @@
* with no lane is `FAIL lanes — <file> has no test lane`, never a pass.
* Markdown no test depends on is the docs/no-tests empty lane instead
* (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does.
* A release manifest whose diff against the merge base changes only version
* values is waived as version-stamp-only (lib/pregate/stamp-only.ts) and named
* on its own line; any other change to it still needs a lane.
* A test that reads the file still selects that test.
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
@@ -328,6 +331,7 @@ function describe(sel: Selection): string[] {
const lines = sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`);
if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`);
if (sel.inert.length) lines.push(`no-tests (assets and tool config): ${sel.inert.join(' ')}`);
if (sel.stampOnly.length) lines.push(`version-stamp-only (no lane needed): ${sel.stampOnly.join(' ')} — only version values changed against the merge base; a manifest whose other content changed still needs a lane`);
for (const r of sel.remote) lines.push(`requires-remote ${r.lane} (${r.platform}): ${r.tests.length} test(s) — ${r.tests.slice(0, 8).join(' ')}${r.tests.length > 8 ? ' …' : ''}`);
lines.push(`Tiers ran: ${sel.tiersRan.join(' ') || '(none)'}; requires-remote: ${[...new Set(sel.remote.map(r => r.platform))].join(' ') || 'none'}`);
return lines;
@@ -414,10 +418,9 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) };
}
if (sel.tests.length === 0) {
const inert = sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '';
const detail = sel.emptyLane.length
? `docs/no-tests: ${sel.emptyLane.length} documentation file(s)${inert ? ` and ${inert}` : ''}; nothing touched selects a test`
: inert ? `no-tests: ${inert}; nothing touched selects a test`
const classes = [sel.emptyLane.length ? `${sel.emptyLane.length} documentation file(s)` : '', sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '', sel.stampOnly.length ? `${sel.stampOnly.length} version-stamp-only manifest(s)` : ''].filter(Boolean);
const label = sel.emptyLane.length ? 'docs/no-tests' : sel.inert.length ? 'no-tests' : 'version-stamp-only';
const detail = classes.length ? `${label}: ${classes.join(' and ')}; nothing touched selects a test`
: ctx.touched.length ? 'nothing touched selects a test' : 'no touched files';
return { ...base, detail };
}
+6
View File
@@ -76,6 +76,12 @@ export function isDocsEmptyLane(rel: string, extraGlobs: readonly string[] = [])
return isBuiltinDocsPath(norm);
}
/** True under a test directory or a protected tree (snapshots, goldens, baselines, fixtures, CI workflows and actions, `.gstack/`): never an empty lane, never a version stamp. */
export function isProtectedTree(rel: string): boolean {
const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
return isTestTree(norm) || PROTECTED_TREE.test(norm);
}
/** Tool configuration on the allowlist: a named dotfile at any depth, or a file under an editor or design-tool dot-directory. */
function isToolConfig(rel: string): boolean {
return INERT_DOTFILE.test(rel.split('/').pop() ?? rel) || INERT_DOT_DIR.test(rel);
+43 -2
View File
@@ -7,12 +7,16 @@
* runner-owned selection manifest `.gstack/pregate.json` declares for a
* planner-computed matrix. Every uncertainty widens the selection.
*/
import { spawnSync } from 'node:child_process';
import { GENERATED_PATH } from '../regen';
import { runBashSync } from '../script-invocation';
import { matchesAny } from '../ship-policy';
import { PREGATE_CONFIG_PATH } from './config';
import { isDocsEmptyLane, isInertNoTests } from './docs-lane';
import { isDocsEmptyLane, isInertNoTests, isProtectedTree } from './docs-lane';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { buildGraph, isTestFile, listRepoFiles, testsFor, type Graph } from './import-graph';
import { stampFileKind, versionStampOnly } from './stamp-only';
import type { PregateContext, RemoteObligation } from './types';
import type { Lane } from './workflows';
@@ -23,6 +27,8 @@ export interface Selection {
emptyLane: string[];
/** Assets and dotfile tool config with no import-graph test: files no runner loads, also an empty lane. */
inert: string[];
/** Release manifests whose diff against the merge base changes only version values (lib/pregate/stamp-only.ts): waived, no lane needed. */
stampOnly: string[];
/** Every test the touched files select, sorted. */
tests: string[];
/** Tests that run on this machine. */
@@ -70,6 +76,27 @@ export function laneFileSet(ctx: PregateContext, lane: Lane): string[] | '?' | '
return files.size ? [...files] : 'all';
}
const STAMP_FILE_CAP = 1024 * 1024;
/** Strict UTF-8 text of `bytes`, BOM kept as content, or null when invalid (two different invalid bytes would otherwise both decode to U+FFFD and compare equal) or over the cap. */
function strictText(bytes: Buffer | null | undefined): string | null {
if (!bytes || bytes.length > STAMP_FILE_CAP) return null;
try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return null; }
}
/** The file's text at the merge base when it is a regular blob there of mode `mode` (null when absent, a symlink or submodule, another mode, unreadable, invalid UTF-8 or over 1 MiB). */
function readAtMergeBase(ctx: PregateContext, rel: string, mode: string): string | null {
const ls = spawnSync('git', ['ls-tree', ctx.mergeBase, '--', rel], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 60_000, env: ctx.env });
if (ls.status !== 0 || !(ls.stdout ?? '').startsWith(`${mode} blob `)) return null;
const r = spawnSync('git', ['show', `${ctx.mergeBase}:${rel}`], { cwd: ctx.repoRoot, timeout: 60_000, maxBuffer: STAMP_FILE_CAP + 1, env: ctx.env });
return r.status === 0 ? strictText(r.stdout as Buffer) : null;
}
/** The working-tree file's text and git mode under the same rules (a regular file only; a symlink or a mode change is not a stamp). */
function readWorkingTree(repoRoot: string, rel: string): { text: string | null; mode: string } {
try {
const abs = path.join(repoRoot, rel); const st = fs.lstatSync(abs);
return { text: st.isFile() && st.size <= STAMP_FILE_CAP ? strictText(fs.readFileSync(abs)) : null, mode: st.mode & 0o111 ? '100755' : '100644' };
} catch { return { text: null, mode: '100644' }; }
}
/** Tracked files matching a registry entry's output globs. */
function outputsOf(all: readonly string[], globs: readonly string[]): string[] { return all.filter(f => matchesAny(globs, f)); }
@@ -83,7 +110,9 @@ export function selectLanes(ctx: PregateContext): Selection {
const noLane: string[] = [];
const emptyLane: string[] = [];
const inert: string[] = [];
const stampOnly: string[] = [];
const seeds = new Map<string, string>();
const touchedByPath = new Map(touched.map(t => [t.path, t]));
for (const t of touched) { seeds.set(t.path, t.status === 'D' ? 'deleted' : 'touched'); if (t.renamedFrom) seeds.set(t.renamedFrom, `renamed to ${t.path}`); }
for (const e of ctx.registry.entries) {
if (!touched.some(t => matchesAny(e.inputs, t.path) || matchesAny(e.outputs, t.path))) continue;
@@ -118,6 +147,18 @@ export function selectLanes(ctx: PregateContext): Selection {
provenance.push(`${file}: no-tests (asset or tool config no runner loads)`);
continue;
}
// A release manifest the branch only re-stamped: nothing a test could observe changed, so no lane is owed (gbrain #7625). Never under a test, fixture, golden or pin tree: a test may compare against that JSON by a path the graph cannot see.
const entry = touchedByPath.get(file);
if (entry && entry.status === 'M' && !entry.renamedFrom && stampFileKind(file) && !isProtectedTree(file)) {
const after = readWorkingTree(ctx.repoRoot, file);
const verdict = versionStampOnly(file, after.text === null ? null : readAtMergeBase(ctx, file, after.mode), after.text);
if (verdict.stampOnly) {
stampOnly.push(file);
provenance.push(`${file}: version-stamp-only against ${ctx.mergeBase.slice(0, 12)} (${verdict.changed.join('; ')}); no lane needed`);
continue;
}
provenance.push(`${file}: not version-stamp-only (${verdict.reason})`);
}
noLane.push(file);
}
const laneSets: Record<string, string[] | '?'> = {};
@@ -141,7 +182,7 @@ export function selectLanes(ctx: PregateContext): Selection {
if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t);
for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t);
if (local.size && tiers.size === 0) tiers.add('unit');
return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), inert: inert.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance };
return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), inert: inert.sort(), stampOnly: stampOnly.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance };
}
export function isTest(file: string): boolean { return isTestFile(file); }
+178
View File
@@ -0,0 +1,178 @@
/**
* pregate version-stamp-only — a release manifest whose diff against the base
* changes nothing but its version values needs no test lane.
*
* gbrain's `release:restamp` rewrites six plugin manifests, its VERSION file
* and package.json; its base's `.gstack/pregate.json` declares no lane for
* them, and pregate reads policy from the base, so every stamp PR failed
* `lanes` with `PREGATE_NO_LANE` and the PR could not fix itself (gbrain #7625,
* #7606). A stamp changes no behaviour a test could observe, so `lanes` waives
* such a file, naming it and why, when `versionStampOnly` says the before and
* after texts differ only as a stamp does.
*
* The rule is deliberately narrow. Three file kinds, each with its own exact
* definition; anything else, a parse failure, a file absent on either side,
* or an unchanged file is not stamp-only, and the caller falls through to
* `PREGATE_NO_LANE` as before:
*
* - JSON (`*.json`): both texts parse to objects of the same shape (the same
* own keys in the same order in every object, the same length in every
* array, the same type at every leaf); the only differing leaf is the
* document's top-level `version`, a bare version string on both sides
* (`VERSION_TOKEN`: `1.2.3`, `v1.2.3.4`, `1.2.3-rc.1`); and the two texts
* are line-for-line identical once that one changed line has its old quoted
* version replaced by the new one. Nothing nested qualifies, so a dependency
* pin or range, a lockfile entry, a catalog, a devcontainer feature option
* or a package named `version` is never a stamp; nor is a changed
* description, a new, removed or reordered key, a floor such as
* `minHostVersion` or `schemaVersion`, a reflow, a document nested too deep
* to walk, or any change the parser would erase (a big integer, a
* duplicate key).
* - plist (`*.plist`): the same number of lines; every differing line pair is
* a `<string>` holding a bare version on both sides, and the line before it
* is `<key>CFBundleShortVersionString</key>` or `<key>CFBundleVersion</key>`.
* `MinimumOSVersion` and every other key are not stamps.
* - VERSION files (basename `VERSION` or `VERSION.txt`, any case): the whole
* content is one bare version, with at most a trailing LF, on both sides
* (a BOM, a space, a CR or a second line is not).
*
* Texts are compared as they are: a line-ending change, a BOM or any other
* byte the parser would hide is content, and is not a stamp.
*
* Applied by lib/pregate/lane-selection.ts only after the import graph, the
* declarations, the regen registry and the two empty-lane classes found
* nothing, so a manifest a test reads still selects that test; only to a
* modified, unrenamed file; and never under a test, fixture, golden, snapshot,
* `.gstack/` or CI-workflow tree, whose JSON a test may compare against by a
* path the import graph cannot see. The verdict is per file: the other touched files
* keep their own lanes or their own `PREGATE_NO_LANE`.
*/
/** A bare version: optional `v`, two to four dot-separated numbers, an optional pre-release or build suffix. No range operators. */
export const VERSION_TOKEN = /^v?\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?$/;
/** The one JSON key a stamp may change, at the document root only. */
const JSON_VERSION_KEY = 'version';
/** The plist keys a stamp may change. */
const PLIST_VERSION_KEYS = new Set(['CFBundleShortVersionString', 'CFBundleVersion']);
const VERSION_FILE = /^VERSION(?:\.txt)?$/i;
const PLIST_STRING = /^\s*<string>([^<]*)<\/string>\s*$/;
const PLIST_KEY = /^\s*<key>([^<]*)<\/key>\s*$/;
export type StampVerdict = { stampOnly: true; changed: string[] } | { stampOnly: false; reason: string };
const not = (reason: string): StampVerdict => ({ stampOnly: false, reason });
/** Which definition applies to `rel`; null when none does. */
export function stampFileKind(rel: string): 'json' | 'plist' | 'version' | null {
const base = rel.replace(/\\/g, '/').split('/').pop() ?? rel;
if (VERSION_FILE.test(base)) return 'version';
if (/\.json$/i.test(base)) return 'json';
if (/\.plist$/i.test(base)) return 'plist';
return null;
}
type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
interface VersionChange { path: string; from: string; to: string }
function kindOf(v: Json): string {
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v;
}
const isObject = (v: Json): v is { [k: string]: Json } => v !== null && typeof v === 'object' && !Array.isArray(v);
/**
* Walks `a` and `b` together. `key` is the own key `a` sits under (null at the
* root and inside arrays), `at` the display path, `depth` how many containers
* enclose it (1 for a root object's own values). Returns the first structural
* difference as a reason, or null after recording the version change.
*/
function jsonDiff(a: Json, b: Json, key: string | null, at: string, depth: number, changed: VersionChange[]): string | null {
const here = at || '$';
if (kindOf(a) !== kindOf(b)) return `${here} changed type (${kindOf(a)} → ${kindOf(b)})`;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return `${here} changed length (${a.length} → ${b.length})`;
for (let i = 0; i < a.length; i++) { const r = jsonDiff(a[i]!, b[i]!, null, `${at}[${i}]`, depth + 1, changed); if (r) return r; }
return null;
}
if (isObject(a) && isObject(b)) {
const ka = Object.keys(a); const kb = Object.keys(b);
for (const k of ka) if (!Object.hasOwn(b, k)) return `${at}.${k} removed`;
for (const k of kb) if (!Object.hasOwn(a, k)) return `${at}.${k} added`;
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return `${here} keys reordered (${ka[i]} → ${kb[i]} at position ${i})`;
for (const k of ka) { const r = jsonDiff(a[k]!, b[k]!, k, `${at}.${k}`, depth + 1, changed); if (r) return r; }
return null;
}
if (a === b) return null;
if (typeof a !== 'string' || typeof b !== 'string') return `${here} changed and is not a version string`;
if (key !== JSON_VERSION_KEY) return `${here} changed and its key is not \`${JSON_VERSION_KEY}\``;
if (depth !== 1) return `${here} changed and is not the top-level \`${JSON_VERSION_KEY}\``;
if (!VERSION_TOKEN.test(a) || !VERSION_TOKEN.test(b)) return `${here} changed and a value is not a bare version (${JSON.stringify(a)} → ${JSON.stringify(b)})`;
changed.push({ path: at.replace(/^\./, ''), from: a, to: b });
return null;
}
/**
* The texts must differ on exactly one line per recorded version change, each
* such line being the old line with that change's old quoted version replaced
* by the new one, each change consumed once. Rejects what the parser erased:
* reflow, a shadowed duplicate key, number spellings, escape spellings.
*/
function textDiffersOnlyByVersions(a: string, b: string, changed: readonly VersionChange[]): string | null {
const la = a.split('\n'); const lb = b.split('\n');
if (la.length !== lb.length) return `line count changed (${la.length} → ${lb.length})`;
const unused = [...changed];
for (let i = 0; i < la.length; i++) {
if (la[i] === lb[i]) continue;
const k = unused.findIndex(c => la[i]!.includes(JSON.stringify(c.from)) && la[i]!.replace(JSON.stringify(c.from), JSON.stringify(c.to)) === lb[i]);
if (k < 0) return `line ${i + 1} changed beyond a version value`;
unused.splice(k, 1);
}
return unused.length ? `${unused[0]!.path} changed in the document but on no line of its own` : null;
}
/**
* Whether `rel` changed from `before` to `after` as a version stamp and nothing
* else, per the file-kind definitions above. `before` is the file's text at the
* merge base, `after` its working-tree text; null on either side (absent,
* unreadable, not valid UTF-8, or over the caller's size cap) is never a stamp.
*/
export function versionStampOnly(rel: string, before: string | null, after: string | null): StampVerdict {
const kind = stampFileKind(rel);
if (!kind) return not('not a JSON, plist or VERSION file');
if (after === null) return not('absent or unreadable in the working tree');
if (before === null) return not('absent or unreadable at the merge base');
const a = before; const b = after;
if (a === b) return not('unchanged');
if (kind === 'version') {
const x = a.replace(/\n$/, ''); const y = b.replace(/\n$/, '');
if (!VERSION_TOKEN.test(x) || !VERSION_TOKEN.test(y)) return not('content is not one bare version on both sides');
if (x === y) return not('no version changed (trailing newline only)');
return { stampOnly: true, changed: [`${x} → ${y}`] };
}
if (kind === 'plist') {
const la = a.split('\n'); const lb = b.split('\n');
if (la.length !== lb.length) return not(`line count changed (${la.length} → ${lb.length})`);
const changed: string[] = [];
for (let i = 0; i < la.length; i++) {
if (la[i] === lb[i]) continue;
const ma = PLIST_STRING.exec(la[i]!); const mb = PLIST_STRING.exec(lb[i]!);
if (!ma || !mb) return not(`line ${i + 1} changed and is not a <string> value`);
if (!VERSION_TOKEN.test(ma[1]!) || !VERSION_TOKEN.test(mb[1]!)) return not(`line ${i + 1} changed and a value is not a bare version`);
const key = i > 0 ? PLIST_KEY.exec(la[i - 1]!)?.[1] : undefined;
if (key === undefined || !PLIST_VERSION_KEYS.has(key)) return not(`line ${i + 1} changed under ${key === undefined ? 'no <key>' : `<key>${key}</key>`}, not a bundle version key`);
changed.push(`${key} ${ma[1]} → ${mb[1]}`);
}
return changed.length ? { stampOnly: true, changed } : not('no line changed');
}
let ja: Json; let jb: Json;
try { ja = JSON.parse(a); jb = JSON.parse(b); } catch (e) { return not(`not parseable JSON on both sides (${(e as Error).message})`); }
if (!isObject(ja) || !isObject(jb)) return not('not a JSON object on both sides');
const changed: VersionChange[] = [];
let reason: string | null;
try { reason = jsonDiff(ja, jb, null, '', 0, changed); } catch (e) { return not(`could not compare (${e instanceof RangeError ? 'nested too deep' : (e as Error).message})`); }
reason ??= changed.length ? textDiffersOnlyByVersions(a, b, changed) : 'no value changed (whitespace or spelling only)';
if (reason) return not(reason);
return { stampOnly: true, changed: changed.map(c => `${c.path} ${c.from} → ${c.to}`) };
}
+1 -1
View File
@@ -385,7 +385,7 @@ export const RESULT_CODES = {
PREGATE_NO_LANE: {
anchor: 'pregate-no-lane',
summary: 'a touched source file maps to no test lane through imports, repo-path literals, the bin/<name> convention or a declaration',
fix: 'add a test that names the file, or declare its lane (or an explicit empty lane) under `dependencies` in .gstack/pregate.json. Markdown no test depends on is already a docs/no-tests empty lane; other documentation goes in `empty_lanes`',
fix: 'add a test that names the file, or declare its lane (or an explicit empty lane) under `dependencies` in .gstack/pregate.json. Markdown no test depends on is already a docs/no-tests empty lane; other documentation goes in `empty_lanes`; a release manifest whose only change against the merge base is its version values is waived as version-stamp-only',
},
PREGATE_LANE_FAILED: {
anchor: 'pregate-lane-failed',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.243",
"version": "1.91.244",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+1
View File
@@ -65,6 +65,7 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
{ file: 'test/pregate-regate.test.ts', reason: 'its REGATE and MIGRATION_COLLISION verdicts differ on windows-latest, not diagnosed in fix wave PR 5; gstack-pregate regate itself launches through lib/script-invocation.ts' },
{ file: 'test/pregate-invalidation.test.ts', reason: 'expects the Linux-host lanes verdict (the windows lane as the requires-remote obligation); on Windows the Linux lane is the obligation instead; gstack-pregate itself launches through lib/script-invocation.ts' },
{ file: 'test/pregate-selection.test.ts', reason: 'its gstack-pregate (bin) cases expect the Linux-host lanes verdict (a local lane run); on Windows the fixture\'s Linux lane is a requires-remote obligation' },
{ file: 'test/pregate-stamp-only.test.ts', reason: 'its gstack-pregate (bin) cases expect the Linux-host lanes verdict for the mapped-file and test-reads-manifest fixtures, and its mode-change case needs a POSIX exec bit and symlink; the stamp rule it drives is POSIX-run like the other pregate fixtures' },
{ file: 'test/pregate-bats-nested.test.ts', reason: 'expects the bats lane to run locally, which it does on a Linux host; on Windows the fixture\'s Linux lane is a requires-remote obligation' },
{ file: 'test/pregate-docs-lane-review.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess); the docs-lane selection rules it drives are POSIX-run like the other pregate fixtures' },
{ file: 'test/pregate-dart.test.ts', reason: 'its dart, flutter and fvm stand-ins are bash scripts on a colon-joined PATH, which Bun.which and CreateProcess cannot use on Windows, and its containment case needs a POSIX symlink; the Dart lane code it drives is POSIX-run like the other pregate fixtures' },
+308
View File
@@ -0,0 +1,308 @@
/**
* pregate version-stamp-only (lib/pregate/stamp-only.ts, lib/pregate/lane-selection.ts):
* a release manifest whose diff against the merge base changes only its
* version values needs no test lane, so a restamp PR on a base whose
* .gstack/pregate.json declares no lane for its manifests no longer fails
* `lanes` with PREGATE_NO_LANE (gbrain #7625, #7606). A manifest whose other
* content changed, one added, deleted or renamed, or one a test reads, keeps
* its lane or its failure.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { stampFileKind, VERSION_TOKEN, versionStampOnly } from '../lib/pregate/stamp-only';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const pregate = (repo: FixtureRepo, args: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...args]);
const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes');
const WAIVER = 'lanes: version-stamp-only (no lane needed): ';
const manifest = (version: string, extra: Record<string, unknown> = {}) => JSON.stringify({ name: 'gbrain', version, description: 'Personal knowledge brain', author: { name: 'Garry Tan' }, ...extra }, null, 2) + '\n';
const j = (v: unknown) => JSON.stringify(v, null, 2) + '\n';
describe('versionStampOnly (pure)', () => {
test('VERSION_TOKEN accepts bare versions and refuses ranges, words and dates', () => {
for (const ok of ['1.2', '1.2.3', 'v1.2.3', '0.60.462.0', '1.2.3-rc.1', '1.2.3+build.7']) expect(VERSION_TOKEN.test(ok)).toBe(true);
for (const no of ['^1.2.3', '~1.2.3', '>=1.2.3', '1', '1.2.3.4.5', 'latest', '1.2.3 ', 'v', '2026-10-26']) expect(VERSION_TOKEN.test(no)).toBe(false);
});
test('file kinds: JSON, plist and VERSION files (VERSION, VERSION.txt, version.txt, any case); nothing else', () => {
expect(stampFileKind('.claude-plugin/plugin.json')).toBe('json');
expect(stampFileKind('openclaw.plugin.json')).toBe('json');
expect(stampFileKind('ios/App/Info.plist')).toBe('plist');
expect(stampFileKind('VERSION')).toBe('version');
expect(stampFileKind('packages/core/VERSION.txt')).toBe('version');
expect(stampFileKind('version.txt')).toBe('version');
expect(stampFileKind('sub\\dir\\Version')).toBe('version');
for (const other of ['package-lock.json.bak', 'src/version.ts', 'Cargo.toml', 'pyproject.toml', 'CHANGELOG.md', 'llms-full.txt', 'versions', 'VERSIONS']) expect(stampFileKind(other)).toBeNull();
});
test('a JSON manifest whose only change is its top-level version value is stamp-only, naming the change; nothing nested qualifies', () => {
expect(versionStampOnly('.claude-plugin/plugin.json', manifest('0.60.462.0'), manifest('0.60.463.0'))).toEqual({ stampOnly: true, changed: ['version 0.60.462.0 → 0.60.463.0'] });
expect(versionStampOnly('x.json', '{"version":"v1.2.3","b":[1,2]}\n', '{"version":"v1.2.4","b":[1,2]}\n')).toEqual({ stampOnly: true, changed: ['version v1.2.3 → v1.2.4'] });
expect(versionStampOnly('x.json', j({ plugin: { version: 'v1.2.3' } }), j({ plugin: { version: 'v1.2.4' } }))).toEqual({ stampOnly: false, reason: '.plugin.version changed and is not the top-level `version`' });
expect(versionStampOnly('x.json', j({ version: '1.0.0', build: { version: '1.0.0' } }), j({ version: '1.0.1', build: { version: '1.0.1' } }))).toEqual({ stampOnly: false, reason: '.build.version changed and is not the top-level `version`' });
expect(versionStampOnly('x.json', j([{ version: '1.0.0' }]), j([{ version: '1.0.1' }]))).toEqual({ stampOnly: false, reason: 'not a JSON object on both sides' });
expect(versionStampOnly('x.json', '"1.0.0"', '"1.0.1"')).toEqual({ stampOnly: false, reason: 'not a JSON object on both sides' });
});
test('a JSON manifest whose non-version content changed is not stamp-only, with the path in the reason', () => {
expect(versionStampOnly('p.json', manifest('1.0.0'), manifest('1.0.0', { description: 'Personal knowledge brain, now with RLS' }))).toEqual({ stampOnly: false, reason: '.description changed and its key is not `version`' });
const mixed = versionStampOnly('p.json', manifest('1.0.0'), manifest('1.0.1', { description: 'changed too' }));
expect(mixed).toEqual({ stampOnly: false, reason: '.description changed and its key is not `version`' });
expect(versionStampOnly('p.json', manifest('1.0.0'), manifest('1.0.1', { keywords: ['a'] }))).toEqual({ stampOnly: false, reason: '.keywords added' });
expect(versionStampOnly('p.json', manifest('1.0.0', { keywords: ['a'] }), manifest('1.0.1'))).toEqual({ stampOnly: false, reason: '.keywords removed' });
expect(versionStampOnly('p.json', j({ version: '1.0.0', files: ['a', 'b'] }), j({ version: '1.0.1', files: ['b', 'a'] }))).toEqual({ stampOnly: false, reason: '.files[0] changed and its key is not `version`' });
expect(versionStampOnly('p.json', j({ version: '1.0.0', files: ['a'] }), j({ version: '1.0.1', files: ['a', 'b'] }))).toEqual({ stampOnly: false, reason: '.files changed length (1 → 2)' });
expect(versionStampOnly('p.json', j({ version: '1.0.0', count: 1 }), j({ version: '1.0.1', count: 2 }))).toEqual({ stampOnly: false, reason: '.count changed and is not a version string' });
expect(versionStampOnly('p.json', j({ version: '1.0.0', count: 1 }), j({ version: '1.0.1', count: '1' }))).toEqual({ stampOnly: false, reason: '.count changed type (number → string)' });
expect(versionStampOnly('p.json', j({ version: ['1.0.0'] }), j({ version: ['1.0.1'] }))).toEqual({ stampOnly: false, reason: '.version[0] changed and its key is not `version`' });
});
test('a reordered object is not a stamp even with a version bump (conditional exports resolve in key order)', () => {
const before = j({ version: '1.0.0', exports: { '.': { import: './a.mjs', default: './a.cjs' } } });
const after = j({ version: '1.0.1', exports: { '.': { default: './a.cjs', import: './a.mjs' } } });
expect(versionStampOnly('package.json', before, after)).toEqual({ stampOnly: false, reason: '.exports.. keys reordered (import → default at position 0)' });
expect(versionStampOnly('package.json', j({ name: 'x', version: '1.0.0' }), j({ version: '1.0.1', name: 'x' }))).toEqual({ stampOnly: false, reason: '$ keys reordered (name → version at position 0)' });
});
test('a key with a prototype name added, removed or changed is seen (own keys only)', () => {
for (const k of ['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf']) {
expect(versionStampOnly('p.json', '{"version":"1.0.0"}', `{"version":"1.0.1",${JSON.stringify(k)}:"evil"}`)).toEqual({ stampOnly: false, reason: `.${k} added` });
expect(versionStampOnly('p.json', `{"version":"1.0.0",${JSON.stringify(k)}:"a"}`, '{"version":"1.0.1"}')).toEqual({ stampOnly: false, reason: `.${k} removed` });
expect(versionStampOnly('p.json', `{"version":"1.0.0",${JSON.stringify(k)}:"a"}`, `{"version":"1.0.1",${JSON.stringify(k)}:"b"}`)).toEqual({ stampOnly: false, reason: `.${k} changed and its key is not \`version\`` });
}
expect(versionStampOnly('p.json', '{"version":"1.0.0","__proto__":{"x":1}}', '{"version":"1.0.1","__proto__":{"x":1}}').stampOnly).toBe(true);
});
test('only a key named exactly version qualifies: floors, schema versions, package names and dependency maps are not stamps', () => {
expect(versionStampOnly('p.json', j({ version: '1.0.0', minHostVersion: '1.0.0' }), j({ version: '1.0.1', minHostVersion: '9.0.0' }))).toEqual({ stampOnly: false, reason: '.minHostVersion changed and its key is not `version`' });
for (const k of ['schemaVersion', 'apiVersion', 'versionName', 'Version', 'VERSION', 'minimumVersion']) expect(versionStampOnly('p.json', j({ [k]: '1.0.0' }), j({ [k]: '1.0.1' })).stampOnly).toBe(false);
expect(versionStampOnly('package.json', j({ version: '1.0.0', devDependencies: { 'standard-version': '9.5.0' } }), j({ version: '1.0.1', devDependencies: { 'standard-version': '9.5.1' } }))).toEqual({ stampOnly: false, reason: '.devDependencies.standard-version changed and its key is not `version`' });
expect(versionStampOnly('package.json', j({ version: '1.0.0', dependencies: { version: '1.2.3' } }), j({ version: '1.0.1', dependencies: { version: '1.2.4' } }))).toEqual({ stampOnly: false, reason: '.dependencies.version changed and is not the top-level `version`' });
expect(versionStampOnly('package-lock.json', j({ version: '1.0.0', packages: { 'node_modules/lodash': { version: '4.17.20' } } }), j({ version: '1.0.0', packages: { 'node_modules/lodash': { version: '4.17.21' } } }))).toEqual({ stampOnly: false, reason: '.packages.node_modules/lodash.version changed and is not the top-level `version`' });
expect(versionStampOnly('composer.lock', j({ packages: [{ version: 'v1.0.0' }] }), j({ packages: [{ version: 'v1.0.1' }] })).stampOnly).toBe(false);
expect(versionStampOnly('package.json', j({ version: '1.0.0', catalog: { version: '1.2.3' } }), j({ version: '1.0.1', catalog: { version: '1.2.4' } })).stampOnly).toBe(false);
expect(versionStampOnly('.devcontainer/devcontainer.json', j({ features: { 'ghcr.io/x/node:1': { version: '20.1.0' } } }), j({ features: { 'ghcr.io/x/node:1': { version: '22.1.0' } } })).stampOnly).toBe(false);
expect(versionStampOnly('package.json', j({ version: '1.0.0', engines: { node: '20.0.0' } }), j({ version: '1.0.0', engines: { node: '22.0.0' } })).stampOnly).toBe(false);
expect(versionStampOnly('package.json', j({ version: '1.0.0', dependencies: { left: '^1.2.3' } }), j({ version: '1.0.1', dependencies: { left: '^1.2.4' } })).stampOnly).toBe(false);
expect(versionStampOnly('p.json', j({ version: '1.0.0' }), j({ version: 'latest' }))).toEqual({ stampOnly: false, reason: '.version changed and a value is not a bare version ("1.0.0" → "latest")' });
expect(versionStampOnly('p.json', j({ version: '1.0.0' }), j({ version: '^1.0.1' })).stampOnly).toBe(false);
});
test('a change the parser erases is still a change: big integers, duplicate keys, number spellings, a reflow with the stamp', () => {
expect(versionStampOnly('p.json', '{"version":"1.0.0","id":9007199254740993}', '{"version":"1.0.1","id":9007199254740992}')).toEqual({ stampOnly: false, reason: 'line 1 changed beyond a version value' });
expect(versionStampOnly('p.json', '{"version":"1.0.0","a":1,"a":2}', '{"version":"1.0.1","a":2}').stampOnly).toBe(false);
expect(versionStampOnly('p.json', '{"version":"1.0.0","n":1.0}', '{"version":"1.0.1","n":1}').stampOnly).toBe(false);
expect(versionStampOnly('p.json', '{"version":"1.0.0","s":"\\u0041"}', '{"version":"1.0.1","s":"A"}').stampOnly).toBe(false);
expect(versionStampOnly('p.json', manifest('1.0.0'), JSON.stringify(JSON.parse(manifest('1.0.1'))))).toEqual({ stampOnly: false, reason: 'line count changed (9 → 1)' });
expect(versionStampOnly('p.json', '{"version":"1.0.0",\n"name":"x"}', '{"version":"1.0.1",\n"name": "x"}')).toEqual({ stampOnly: false, reason: 'line 2 changed beyond a version value' });
expect(versionStampOnly('p.json', '{"version":"1.0.0","name":"1.0.0"}', '{"version":"1.0.1","name":"1.0.0"}').stampOnly).toBe(true);
// A shadowed duplicate key holding the old version on its own line: the parser sees one change, the text has two changed lines.
expect(versionStampOnly('m.json', '{\n "description": "1.0.0",\n "description": "real",\n "version": "1.0.0"\n}\n', '{\n "description": "1.0.1",\n "description": "real",\n "version": "1.0.1"\n}\n')).toEqual({ stampOnly: false, reason: 'line 4 changed beyond a version value' });
expect(versionStampOnly('m.json', '{\n "x": "1.0.0",\n "x": "keep",\n "version": "1.0.0"\n}\n', '{\n "x": "2.0.0",\n "x": "keep",\n "version": "2.0.0"\n}\n').stampOnly).toBe(false);
const deep = (v: string) => `{"version":"${v}","a":${'['.repeat(200_000)}1${']'.repeat(200_000)}}`;
expect(versionStampOnly('deep.json', deep('1.0.0'), deep('1.0.1'))).toEqual({ stampOnly: false, reason: 'could not compare (nested too deep)' });
});
test('an absent, unchanged, reformatted or unparseable manifest is not stamp-only, and a non-manifest file never is', () => {
expect(versionStampOnly('p.json', null, manifest('1.0.0'))).toEqual({ stampOnly: false, reason: 'absent or unreadable at the merge base' });
expect(versionStampOnly('p.json', manifest('1.0.0'), null)).toEqual({ stampOnly: false, reason: 'absent or unreadable in the working tree' });
expect(versionStampOnly('p.json', manifest('1.0.0'), manifest('1.0.0'))).toEqual({ stampOnly: false, reason: 'unchanged' });
expect(versionStampOnly('p.json', manifest('1.0.0'), JSON.stringify(JSON.parse(manifest('1.0.0'))))).toEqual({ stampOnly: false, reason: 'no value changed (whitespace or spelling only)' });
expect(versionStampOnly('p.json', manifest('1.0.0'), '{ version: 1.0.1 }').stampOnly).toBe(false);
expect(versionStampOnly('src/version.ts', "export const VERSION = '1.0.0';", "export const VERSION = '1.0.1';")).toEqual({ stampOnly: false, reason: 'not a JSON, plist or VERSION file' });
});
test('a VERSION file is stamp-only when both sides are one bare version with at most a trailing LF; a line-ending change is content', () => {
expect(versionStampOnly('VERSION', '0.60.462.0\n', '0.60.463.0\n')).toEqual({ stampOnly: true, changed: ['0.60.462.0 → 0.60.463.0'] });
expect(versionStampOnly('VERSION', '0.60.462.0', '0.60.463.0')).toEqual({ stampOnly: true, changed: ['0.60.462.0 → 0.60.463.0'] });
expect(versionStampOnly('VERSION', '0.60.462.0\n', '0.60.463.0\r\n')).toEqual({ stampOnly: false, reason: 'content is not one bare version on both sides' });
expect(versionStampOnly('p.json', '{\r\n"version": "1.0.0"\r\n}', '{\n"version": "1.0.1"\n}')).toEqual({ stampOnly: false, reason: 'line 1 changed beyond a version value' });
expect(versionStampOnly('VERSION', '0.60.462.0\n', '0.60.463.0\nnotes\n').stampOnly).toBe(false);
expect(versionStampOnly('VERSION', 'unreleased\n', '1.0.0\n').stampOnly).toBe(false);
expect(versionStampOnly('VERSION', '1.0.0', '1.0.0\n')).toEqual({ stampOnly: false, reason: 'no version changed (trailing newline only)' });
expect(versionStampOnly('VERSION', '1.0.0\n', ' 1.0.1\n')).toEqual({ stampOnly: false, reason: 'content is not one bare version on both sides' });
expect(versionStampOnly('VERSION', '1.0.0\n', '\u00a01.0.0\u2028').stampOnly).toBe(false);
expect(versionStampOnly('VERSION', '1.0.0\n', '\ufeff1.0.1\n').stampOnly).toBe(false);
});
test('a plist is stamp-only when only the <string> under CFBundleShortVersionString or CFBundleVersion changed', () => {
const plist = (short: string, build: string, name = 'App', minOS = '15.0') => `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>${name}</string>
<key>CFBundleShortVersionString</key>
<string>${short}</string>
<key>CFBundleVersion</key>
<string>${build}</string>
<key>MinimumOSVersion</key>
<string>${minOS}</string>
</dict>
</plist>
`;
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.4', '1.2.4.46'))).toEqual({ stampOnly: true, changed: ['CFBundleShortVersionString 1.2.3 → 1.2.4', 'CFBundleVersion 1.2.3.45 → 1.2.4.46'] });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '45'), plist('1.2.4', '46'))).toEqual({ stampOnly: false, reason: 'line 9 changed and a value is not a bare version' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.4', '1.2.4.46', 'Other'))).toEqual({ stampOnly: false, reason: 'line 5 changed and a value is not a bare version' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45', '1.0.0'), plist('1.2.3', '1.2.3.45', '2.0.0'))).toEqual({ stampOnly: false, reason: 'line 5 changed under <key>CFBundleName</key>, not a bundle version key' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.3', '1.2.3.45', 'App', '17.0'))).toEqual({ stampOnly: false, reason: 'line 11 changed under <key>MinimumOSVersion</key>, not a bundle version key' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.4', '1.2.4.46').replace('<key>CFBundleVersion</key>', '<key>CFBundleVersions</key>'))).toEqual({ stampOnly: false, reason: 'line 8 changed and is not a <string> value' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.4', '1.2.4.46') + '<!-- -->\n').stampOnly).toBe(false);
expect(versionStampOnly('v.plist', '<string>1.0.0</string>\n', '<string>1.0.1</string>\n')).toEqual({ stampOnly: false, reason: 'line 1 changed under no <key>, not a bundle version key' });
});
});
describe('gstack-pregate lanes and version-stamp-only manifests', () => {
// The fixture's base pregate.json declares no lane for these manifests, as gbrain's master does not; every PR is judged by the base's policy.
const base = { '.claude-plugin/plugin.json': manifest('1.0.0'), '.codex-plugin/plugin.json': manifest('1.0.0'), 'openclaw.plugin.json': manifest('1.0.0', { id: 'gbrain-context-engine' }), 'VERSION': '1.0.0\n' };
const stamp = (v: string) => ({ '.claude-plugin/plugin.json': manifest(v), '.codex-plugin/plugin.json': manifest(v), 'openclaw.plugin.json': manifest(v, { id: 'gbrain-context-engine' }), 'VERSION': `${v}\n` });
test('positive: a PR that only re-stamps the manifests and VERSION passes lanes with a line naming the waived files and why', () => {
const repo = fixture({ baseFiles: base });
commit(repo, stamp('1.0.1'), 'release: restamp 1.0.1');
const r = pregate(repo);
expect(r.err).not.toContain('PREGATE_NO_LANE');
expect(r.out).not.toContain('has no test lane');
expect(r.out).toContain(`${WAIVER}.claude-plugin/plugin.json .codex-plugin/plugin.json VERSION openclaw.plugin.json — only version values changed against the merge base; a manifest whose other content changed still needs a lane`);
expect(r.status).toBe(EXIT.ok);
expect(lanes(repo).status).toBe('pass');
expect(lanes(repo).detail).toBe('version-stamp-only: 4 version-stamp-only manifest(s); nothing touched selects a test');
const explain = pregate(repo, ['--explain']).out;
expect(explain).toMatch(/\.claude-plugin\/plugin\.json: version-stamp-only against [0-9a-f]{12} \(version 1\.0\.0 → 1\.0\.1\); no lane needed/);
expect(explain).toMatch(/VERSION: version-stamp-only against [0-9a-f]{12} \(1\.0\.0 → 1\.0\.1\); no lane needed/);
});
test('negative: a manifest whose non-version field changed still fails with PREGATE_NO_LANE and says why it was not a stamp', () => {
const repo = fixture({ baseFiles: base });
commit(repo, { '.claude-plugin/plugin.json': manifest('1.0.1', { description: 'Personal knowledge brain, with RLS' }), 'VERSION': '1.0.1\n' }, 'release: restamp and reword');
const r = pregate(repo);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain('(PREGATE_NO_LANE)');
expect(r.out).toContain('FAIL lanes — .claude-plugin/plugin.json has no test lane');
expect(r.out).toContain(`${WAIVER}VERSION — `);
expect(r.out).not.toContain(`${WAIVER}.claude-plugin`);
expect(pregate(repo, ['--explain']).out).toContain('.claude-plugin/plugin.json: not version-stamp-only (.description changed and its key is not `version`)');
});
test('mixed diff: the stamped manifests are waived while an unmapped source file keeps its PREGATE_NO_LANE, and a mapped one still runs its test', () => {
const repo = fixture({ baseFiles: base });
commit(repo, { 'VERSION': '1.0.1\n', 'openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine' }), 'lib/orphan.js': 'exports.lonely = () => 2;\n' }, 'stamp plus an orphan');
const orphan = pregate(repo);
expect(orphan.status).toBe(EXIT.fail);
expect(orphan.out).toContain('FAIL lanes — lib/orphan.js has no test lane');
expect(orphan.out).toContain(`${WAIVER}VERSION openclaw.plugin.json — `);
expect(pregate(repo, ['--explain']).out).not.toContain('lib/orphan.js: not version-stamp-only');
const repo2 = fixture({ baseFiles: base });
commit(repo2, { 'VERSION': '1.0.1\n', 'openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine' }), 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'stamp plus a mapped change');
const mapped = pregate(repo2);
expect(mapped.status).toBe(EXIT.ok);
expect(mapped.out).toContain(`${WAIVER}VERSION openclaw.plugin.json — `);
expect(lanes(repo2).detail).toMatch(/^1 tests ran in 1 file\(s\), exit 0/);
});
test('a manifest a test reads keeps its lane: the test runs and the file is not waived', () => {
// lib/config.json is read by test/config-read.test.js through a repo-path literal in the fixture.
const repo = fixture({ baseFiles: { 'lib/config.json': j({ version: '1.0.0', limit: 10 }) } });
commit(repo, { 'lib/config.json': j({ version: '1.0.1', limit: 10 }) }, 'stamp a config a test reads');
const r = pregate(repo);
expect(r.out).not.toContain('version-stamp-only');
expect(r.out).toContain("ran: node --test 'test/config-read.test.js'");
expect(lanes(repo).detail).toMatch(/^1 tests ran in 1 file\(s\)/);
});
test('the comparison is against the merge base: main re-stamping after the fork does not change the verdict, and a later non-stamp commit on the branch does', () => {
const repo = fixture({ baseFiles: base });
commit(repo, stamp('1.0.1'), 'release: restamp 1.0.1');
repo.git('checkout', '-q', 'main');
for (const [rel, text] of Object.entries(stamp('1.0.5'))) write(repo.work, rel, text);
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'main restamps 1.0.5'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
const stamped = pregate(repo);
expect(stamped.status).toBe(EXIT.ok);
expect(pregate(repo, ['--explain']).out).toMatch(/VERSION: version-stamp-only against [0-9a-f]{12} \(1\.0\.0 → 1\.0\.1\)/);
commit(repo, { 'openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine', description: 'reworded later' }) }, 'reword after the stamp');
const reworded = pregate(repo);
expect(reworded.status).toBe(EXIT.fail);
expect(reworded.out).toContain('FAIL lanes — openclaw.plugin.json has no test lane');
expect(reworded.out).toContain(`${WAIVER}.claude-plugin/plugin.json .codex-plugin/plugin.json VERSION — `);
});
test('a mode change or a symlink on either side is not a stamp', () => {
const repo = fixture({ baseFiles: base });
fs.chmodSync(path.join(repo.work, 'VERSION'), 0o755);
commit(repo, { 'VERSION': '1.0.1\n' }, 'stamp with an exec bit');
expect(pregate(repo).out).toContain('FAIL lanes — VERSION has no test lane');
const link = fixture({ baseFiles: { 'target.txt': '1.0.0\n' } });
fs.symlinkSync('target.txt', path.join(link.work, 'VERSION'));
link.git('add', '-A'); link.git('commit', '-q', '-m', 'symlinked VERSION'); link.git('push', '-q', 'origin', 'HEAD:main'); link.git('fetch', '-q', 'origin');
fs.unlinkSync(path.join(link.work, 'VERSION')); write(link.work, 'VERSION', '1.0.1\n');
link.git('add', '-A'); link.git('commit', '-q', '-m', 'symlink becomes a file');
const r = pregate(link, ['--explain']);
expect(r.out).toContain('FAIL lanes — VERSION has no test lane');
expect(r.out).toContain('VERSION: not version-stamp-only (absent or unreadable at the merge base)');
});
test('a renamed manifest, an added one and a deleted one are not stamps', () => {
const repo = fixture({ baseFiles: base });
fs.mkdirSync(path.join(repo.work, 'plugins'));
repo.git('mv', 'openclaw.plugin.json', 'plugins/openclaw.plugin.json');
commit(repo, { 'plugins/openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine' }), 'VERSION': null, 'new.plugin.json': manifest('1.0.1') }, 'move, add and delete');
const r = pregate(repo);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toContain('FAIL lanes — new.plugin.json has no test lane');
expect(r.out).toContain('FAIL lanes — plugins/openclaw.plugin.json has no test lane');
expect(r.out).not.toContain('version-stamp-only (no lane needed)');
});
test('the working tree decides: an uncommitted stamp is waived, an uncommitted reword on top of a committed stamp is not, and an oversize or non-UTF-8 manifest is never a stamp', () => {
const repo = fixture({ baseFiles: base });
write(repo.work, 'VERSION', '1.0.1\n');
expect(pregate(repo).status).toBe(EXIT.ok);
expect(lanes(repo).detail).toBe('version-stamp-only: 1 version-stamp-only manifest(s); nothing touched selects a test');
commit(repo, { 'openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine' }) }, 'stamp');
write(repo.work, 'openclaw.plugin.json', manifest('1.0.1', { id: 'gbrain-context-engine', description: 'reworded in the working tree' }));
const dirty = pregate(repo);
expect(dirty.status).toBe(EXIT.fail);
expect(dirty.out).toContain('FAIL lanes — openclaw.plugin.json has no test lane');
const big = fixture({ baseFiles: { 'big.plugin.json': manifest('1.0.0', { pad: 'x'.repeat(1024 * 1024) }), 'bad.plugin.json': manifest('1.0.0'), 'bom.plugin.json': manifest('1.0.0') } });
commit(big, { 'big.plugin.json': manifest('1.0.1', { pad: 'x'.repeat(1024 * 1024) }) }, 'oversize stamp');
fs.writeFileSync(path.join(big.work, 'bad.plugin.json'), Buffer.concat([Buffer.from('{"version":"1.0.1","name":"'), Buffer.from([0xff]), Buffer.from('"}\n')]));
fs.writeFileSync(path.join(big.work, 'bom.plugin.json'), Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(manifest('1.0.1'))]));
big.git('add', '-A'); big.git('commit', '-q', '-m', 'invalid utf-8');
const r = pregate(big, ['--explain']);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toContain('FAIL lanes — big.plugin.json has no test lane');
expect(r.out).toContain('FAIL lanes — bad.plugin.json has no test lane');
expect(r.out).toContain('big.plugin.json: not version-stamp-only (absent or unreadable in the working tree)');
expect(r.out).toContain('bad.plugin.json: not version-stamp-only (absent or unreadable in the working tree)');
expect(r.out).toContain('FAIL lanes — bom.plugin.json has no test lane');
expect(r.out).toMatch(/bom\.plugin\.json: not version-stamp-only \(not parseable JSON on both sides/);
});
test('a manifest under a test, fixture, golden, .gstack or workflow tree is never waived', () => {
const files = { 'test/fixtures/golden.json': j({ version: '1.0.0', expect: 1 }), 'test/golden/expected.json': j({ version: '1.0.0' }), 'goldens/out.json': j({ version: '1.0.0' }), '.gstack/extra.json': j({ version: '1.0.0' }), '.github/workflows/meta.json': j({ version: '1.0.0' }), 'VERSION': '1.0.0\n' };
const repo = fixture({ baseFiles: files });
commit(repo, Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v.replace('1.0.0', '1.0.1')])), 'stamp everything');
const r = pregate(repo, ['--explain']);
expect(r.status).toBe(EXIT.fail);
for (const f of Object.keys(files).filter(f => f !== 'VERSION')) expect(r.out).toContain(`FAIL lanes — ${f} has no test lane`);
expect(r.out).toContain(`${WAIVER}VERSION — `);
expect(r.out).not.toContain('golden.json: not version-stamp-only');
});
test('the no-tests detail names the stamped manifests beside documentation and assets', () => {
const repo = fixture({ baseFiles: base });
commit(repo, { 'VERSION': '1.0.1\n', 'docs/README.md': '# docs, reworded\n' }, 'stamp plus docs');
expect(pregate(repo).status).toBe(EXIT.ok);
expect(lanes(repo).detail).toBe('docs/no-tests: 1 documentation file(s) and 1 version-stamp-only manifest(s); nothing touched selects a test');
const repo2 = fixture({ baseFiles: base });
commit(repo2, { 'VERSION': '1.0.1\n', 'assets/logo.png': 'not really a png\n' }, 'stamp plus an asset');
expect(pregate(repo2).status).toBe(EXIT.ok);
expect(lanes(repo2).detail).toBe('no-tests: 1 asset or tool-config file(s) and 1 version-stamp-only manifest(s); nothing touched selects a test');
});
});