diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d7bc262..e5177bb09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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): — 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 `` 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. diff --git a/VERSION b/VERSION index 03e07d721..1c57908d7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.243.0 +1.91.244.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index aa284c13c..3a831fa4f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/docs/pregate.md b/docs/pregate.md index 216baf97c..26315aac2 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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 `` versions under + `CFBundleShortVersionString` or `CFBundleVersion`. 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 + (version 1.0.0 → 1.0.1)` or `not version-stamp-only + ()`). 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dcd7117b3..52f919464 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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): `. +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 ()` +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). diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 8d8fdf054..2b135254d 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -7,6 +7,9 @@ * with no lane is `FAIL lanes — 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 { 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 }; } diff --git a/lib/pregate/docs-lane.ts b/lib/pregate/docs-lane.ts index 0644dcef6..3affa1132 100644 --- a/lib/pregate/docs-lane.ts +++ b/lib/pregate/docs-lane.ts @@ -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); diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 529227cd5..e3c41ff1a 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -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(); + 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 = {}; @@ -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); } diff --git a/lib/pregate/stamp-only.ts b/lib/pregate/stamp-only.ts new file mode 100644 index 000000000..14dfe05f3 --- /dev/null +++ b/lib/pregate/stamp-only.ts @@ -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 `` holding a bare version on both sides, and the line before it + * is `CFBundleShortVersionString` or `CFBundleVersion`. + * `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>\s*$/; +const PLIST_KEY = /^\s*([^<]*)<\/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 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}`}, 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}`) }; +} diff --git a/lib/result-codes.ts b/lib/result-codes.ts index b1226dc44..d4e1cf5b4 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -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/ 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', diff --git a/package.json b/package.json index 49a4d108b..c64a23383 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 36dae63ac..fe98f272f 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -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' }, diff --git a/test/pregate-stamp-only.test.ts b/test/pregate-stamp-only.test.ts new file mode 100644 index 000000000..c5a767f2d --- /dev/null +++ b/test/pregate-stamp-only.test.ts @@ -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[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 = {}) => 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 under CFBundleShortVersionString or CFBundleVersion changed', () => { + const plist = (short: string, build: string, name = 'App', minOS = '15.0') => ` + + + CFBundleName + ${name} + CFBundleShortVersionString + ${short} + CFBundleVersion + ${build} + MinimumOSVersion + ${minOS} + + +`; + 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 CFBundleName, 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 MinimumOSVersion, 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('CFBundleVersion', 'CFBundleVersions'))).toEqual({ stampOnly: false, reason: 'line 8 changed and is not a 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', '1.0.0\n', '1.0.1\n')).toEqual({ stampOnly: false, reason: 'line 1 changed under no , 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'); + }); +});