mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
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:
1 parent
8018ba52fe
commit
d62eb914dc
13 files changed
+598
-10
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user