mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
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>
179 lines
10 KiB
TypeScript
179 lines
10 KiB
TypeScript
/**
|
|
* 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}`) };
|
|
}
|