fix(stamp): one side-aware stamp bound for the tree receipt, carry and finish (GST-963)

A stamp row the branch added at its fork version now passes every stamp check, not only the restamp writer: stampOnlyLine takes the branch side (its own lines and fork version), and stampedFiles, the tree receipt and the receipt carry all pass it. The stamp-only checks move to lib/stamp-only.ts to keep lib/restamp.ts under the module-size ratchet.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 10:03:59 +00:00
1 parent 4b308f0261
commit be94808297
14 files changed
+171 -74

No files matched your search

+4 -3
View File
@@ -37,6 +37,7 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { branchSides, type BranchSide } from '../lib/stamp-only';
import { runScriptSync } from '../lib/script-invocation';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
@@ -495,7 +496,7 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[]
* change, such as a CHANGELOG fragment next to the base's changelog.d/README.md
* (GST-343, #3606), into the product patch-id at one head only.
*/
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]> }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]>; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
const changedRaw = git(['diff', '--name-only', from, to], repoRoot);
if (changedRaw === undefined) return undefined;
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
@@ -507,7 +508,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
const versions = new Map<string, string | null>();
const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; };
const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p);
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates } : undefined);
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, side: a === from && b === to ? branch?.own(p) : undefined } : undefined);
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) }));
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path)
@@ -620,7 +621,7 @@ function cmdCarry(rest: string[], json: boolean): number {
const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)];
if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
const touched = new Set([...listed[0], ...listed[1]]);
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]] });
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') });
if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
const { changed } = spec;
const productFrom = patchId(repoRoot, fromBase, from, spec.product);
+2 -2
View File
@@ -64,10 +64,10 @@ function main(argv: string[]): number {
if (predSha || predPr || predRef) predecessor = { pr: predPr ?? predecessor?.pr ?? null, recordedHead: predSha ?? predecessor?.recordedHead ?? null, ref: predRef ?? null };
const policyFrom = flag(argv, '--policy-from') ?? 'base';
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
let loaded: LoadedPolicy;
if (policyFrom === 'head') loaded = loadPolicyFromHead(repoRoot);
else {
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
const l = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(l)) { process.stderr.write(policyUnavailableLine('gstack-tree-receipt', base, l.detail) + '\n'); return EXIT.refused; }
loaded = l;
@@ -76,7 +76,7 @@ function main(argv: string[]): number {
const receipt = buildReceipt({
repoRoot, gated, current: flag(argv, '--current'), policy: loaded.policy, policyLabel: loaded.source.kind === 'head' ? 'head (unreviewed)' : loaded.source.label,
predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'),
predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'), baseRef: `origin/${base}`,
});
if ('error' in receipt) { process.stderr.write(resultLine('gstack-tree-receipt', receipt.code, receipt.error) + '\n'); return EXIT.fail; }
process.stdout.write(json ? JSON.stringify(receipt) + '\n' : renderReceipt(receipt));
@@ -3,7 +3,9 @@
### Fixed
- **`lib/restamp.ts`:** a stamp_patterns match on a branch-added line that names the fork point's version is rewritten to the new version, like a mention of a newer tentative version. Lines the branch did not add, and versions below the fork point, are left alone.
- **`lib/ship-bin-common.ts`:** finish routes such a line as metadata (a stamp file), not behavior.
- **One stamp bound for the writer and every check** (`lib/restamp.ts` `stampOnlyLine` with the branch's side): the tree receipt behind `gstack-ship-queue stamp`, `verify` and the gate-ahead replay, the /ship receipt carry, and finish's route now accept a moved version on a line the branch added at or above the fork-point version. Before, the tree receipt and the carry still floored every line at the gated head's VERSION, so the stamp commit the writer now makes would have read `TREE: changed` and stopped the train with `QUEUE_STAMP_NOT_STAMP_ONLY`. The same move on a base line is still refused.
### Tests
- `test/restamp-mentions.test.ts`: a branch-added row at the fork version after the base moved is restamped and its file is routed as metadata. On main, the stage reports `skipped` and ROUTE counts the file as behavior.
- `test/ship-rereview-triggers.test.ts`: the GST-895 fork-version case now routes as metadata.
- `test/restamp-mentions.test.ts`: `gstack-tree-receipt` reads the GST-963 stamp commit as `same-modulo-stamps` (it read `TREE: changed` before the shared bound), refuses the same move on a base row, and still accepts the GST-895 lost-slot stamp. `test/gstack-ship-receipt-docs.test.ts`: the carry takes the fork-version bound only on the branch's own line.
+1 -1
View File
@@ -1523,7 +1523,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp once the base has moved past it; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the /ship receipt carry, and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION.
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
+2 -29
View File
@@ -362,36 +362,9 @@ export function stampMentions(text: string, templates: readonly string[] = []):
return found;
}
/**
* A removed/added line pair that a restamp alone explains: every version the
* removed line mentions in a stamp form and the added line no longer does
* moves to `newV`, and the rewritten line is the added line byte for byte.
* The moved versions are the ones the stamp_paths stage moves, not only the
* old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = '<next
* minor>'` never equals the base version at the slot and is a stamp all the
* same (#3512). The stage's own bound applies: a tentative mention is newer
* than the version the branch was written against, so a moved version older
* than `oldV` (a released key moved by hand) is not a stamp, and neither is
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
* moves nothing, or changes anything beyond those mentions, is not one either.
*/
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = []): boolean {
if (removed === added) return false;
const floor = parseVersion(oldV);
const width = versionWidth(oldV);
const after = stampMentions(added, templates);
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
if (!moved.length || !floor) return false;
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
let text = removed;
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
return text === added;
}
/** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */
export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string {
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim();
export function forkVersion(repoRoot: string, baseRef: string, fallback: string, head = 'HEAD'): string {
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..${head}`]).out.split('\n')[0]?.trim();
if (!first) return fallback;
const v = versionAtRef(repoRoot, `${first}^`);
return 'version' in v ? v.version : fallback;
+4 -20
View File
@@ -10,7 +10,8 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fragmentSlug } from './changelog-fragments';
import { branchAddedLines, forkVersion, parseUnifiedDiff, stampOnlyLine, versionAtRef } from './restamp';
import { parseUnifiedDiff, versionAtRef } from './restamp';
import { branchSides, stampOnlyLine } from './stamp-only';
import { matchesAny } from './ship-policy';
import { extractVersion } from './version-source';
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
@@ -123,25 +124,8 @@ export function stampedFiles(policy: Record<string, any> | null, cwd: string, re
const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : [];
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]);
if (diff.status !== 0) return [];
let fork: string | undefined;
let since: string | undefined;
const own = new Map<string, Set<string>>();
const ownLines = (rel: string): Set<string> => {
if (own.has(rel)) return own.get(rel)!;
since ??= git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim();
let lines = new Set<string>();
if (since) {
try { lines = branchAddedLines(cwd, since, [rel], ref, branchAddedLines(cwd, since, [rel], `origin/${base}`)).get(rel)?.text ?? lines; } catch {}
}
own.set(rel, lines);
return lines;
};
const stamp = (rel: string, removed: string, added: string): boolean => {
if (stampOnlyLine(removed, added, before.version, after, templates)) return true;
if (!ownLines(rel).has(removed)) return false;
fork ??= forkVersion(cwd, `origin/${base}`, before.version);
return stampOnlyLine(removed, added, fork, after, templates);
};
const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref);
const stamp = (rel: string, removed: string, added: string): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel));
return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 &&
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path);
}
+1 -1
View File
@@ -66,7 +66,7 @@ export function receiptDigest(r: TreeReceipt): string {
}
function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, current: string): TreeReceipt | QueueFailure {
const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label });
const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label, baseRef: `${ctx.remote ?? 'origin'}/${ctx.base}` });
return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r;
}
+5 -4
View File
@@ -12,7 +12,7 @@ import { FRAGMENT_README } from './changelog-fragments';
import { renderIssueLines } from './issue-links';
import { matchesAny } from './ship-policy';
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
import { stampOnlyLine } from './restamp';
import { stampOnlyLine, type BranchSide } from './stamp-only';
export const RECEIPT_FENCE = 'gstack-ship-receipt';
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
@@ -188,13 +188,14 @@ const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test an
* The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else
* `package.json`) and the base policy's `stamp_patterns` templates.
*/
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[] }
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide }
/**
* A policy-named stamp path's change is a stamp when the new text reads as the
* old text once every new version in it is the old one, or when every changed
* line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512): the rule
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512, and
* on a line the branch added itself, `side`, the fork version or newer, GST-963): the rule
* the tree receipt applies to the same `stamp_paths`, `stamp_files` and
* `release_outputs` before the train accepts a stamp commit. No version
* change, or a file missing on either side, is not a stamp.
@@ -209,7 +210,7 @@ export function stampOnlyText(change: CarryChange): boolean {
const now = after.split('\n');
if (was.length !== now.length) return false;
const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]);
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? []));
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side));
}
/**
+70
View File
@@ -0,0 +1,70 @@
/**
* Stamp-only checks: whether a removed/added line pair is a restamp alone, and
* the branch side (its own lines and fork version) that bounds that check for
* lines the branch wrote itself. Split from restamp.ts (module-size ratchet).
*/
import { spawnSync } from 'node:child_process';
import { branchAddedLines, forkVersion, rewriteStamps, stampMentions } from './restamp';
import { cmpVersion, parseVersion, versionWidth } from './version-source';
/**
* A removed/added line pair that a restamp alone explains: every version the
* removed line mentions in a stamp form and the added line no longer does
* moves to `newV`, and the rewritten line is the added line byte for byte.
* The moved versions are the ones the stamp_paths stage moves, not only the
* old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = '<next
* minor>'` never equals the base version at the slot and is a stamp all the
* same (#3512). The stage's own bound applies: a tentative mention is newer
* than the version the branch was written against, so a moved version older
* than `oldV` (a released key moved by hand) is not a stamp, and neither is
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
* moves nothing, or changes anything beyond those mentions, is not one either.
* With `side`, a removed line the branch added itself is bounded by its fork
* point's version instead, the stage's bound for its own lines (GST-895,
* GST-963): finish's stampedFiles, the tree receipt and the receipt carry all
* pass the same side.
*/
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide): boolean {
if (removed === added) return false;
return stampOnlyWithin(removed, added, oldV, newV, templates) || (!!side?.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates));
}
/** Lines the branch added to one file (since its merge base with the base, less the lines the base added since) and its fork point's version. */
export interface BranchSide { fork: string; added: ReadonlySet<string> }
/**
* Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to`
* (`head` unless a reviewed tree stands in for it), the fork version from `head`'s first-parent history
* (`fallback` when it has no own commit). A file git cannot diff has no side, so its lines keep the `oldV` bound.
*/
export function branchSides(repoRoot: string, baseRef: string, head: string, fallback: string, to = head): (rel: string) => BranchSide | undefined {
const sides = new Map<string, BranchSide | undefined>();
let since: string | undefined;
let fork: string | undefined;
return rel => {
if (sides.has(rel)) return sides.get(rel);
since ??= (spawnSync('git', ['merge-base', baseRef, head], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }).stdout ?? '').trim();
let side: BranchSide | undefined;
if (since) {
try {
const added = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel)?.text;
if (added?.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added };
} catch {}
}
sides.set(rel, side);
return side;
};
}
function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean {
const floor = parseVersion(oldV);
const width = versionWidth(oldV);
const after = stampMentions(added, templates);
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
if (!moved.length || !floor) return false;
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
let text = removed;
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
return text === added;
}
+13 -10
View File
@@ -16,7 +16,8 @@ import * as path from 'node:path';
import { normalizeChangelog } from './changelog-check';
import { foldIntoUnreleased, parseFragment, type Fragment } from './changelog-fragments';
import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle';
import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
import { parseUnifiedDiff, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
import { branchSides, stampOnlyLine, type BranchSide } from './stamp-only';
import type { ResultCodeName } from './result-codes';
import { matchesAny, type ShipPolicy } from './ship-policy';
@@ -55,7 +56,7 @@ function blob(cwd: string, treeish: string, rel: string): string | null {
* between the two is a stamp only when the CHANGELOG change is exactly those fragments folded under the new
* heading. gstack-tree-receipt and gstack-pregate clear both classify through this, so their rules agree.
*/
export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict[] {
export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, side?: (rel: string) => BranchSide | undefined): FileVerdict[] {
const prefix = policy.changelog_fragments ? policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null;
const folded = new Map<string, Fragment>();
for (const f of fileDiffs) {
@@ -65,11 +66,11 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st
if (parsed && !('code' in parsed)) folded.set(f.path, parsed);
}
const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1));
const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments);
const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments, side);
const clDiff = fileDiffs.find(f => policy.changelog && f.path === policy.changelog);
const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp';
return fileDiffs.map((f): FileVerdict => {
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV);
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, [], side);
return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${policy.changelog ?? 'the CHANGELOG'}` };
});
}
@@ -77,15 +78,15 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st
/**
* Every removed/added pair differs only by stamp rewrites to the new version:
* the old version, or a tentative one (newer than it) the stamp_paths stage
* moved (#3512), each in a stamp form (`stampOnlyLine`).
* moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines).
*/
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): { why: string; line: number } | null {
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide): { why: string; line: number } | null {
for (const h of hunks) {
if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at };
for (let i = 0; i < h.removed.length; i++) {
const r = h.removed[i]!;
const a = h.added[i]!;
if (!stampOnlyLine(r, a, oldV, newV, templates)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
if (!stampOnlyLine(r, a, oldV, newV, templates, side)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
}
}
return null;
@@ -96,7 +97,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string
return normalizeLockfile(name, a) === normalizeLockfile(name, b);
}
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = []): FileVerdict {
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = [], side?: (rel: string) => BranchSide | undefined): FileVerdict {
const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line });
const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' });
if (f.binary) return changed('binary', 'binary content differs');
@@ -128,7 +129,7 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre
}
const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null;
if (oldV && newV && oldV !== newV && rule) {
const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns);
const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, side?.(f.path));
return bad ? changed(rule, bad.why, bad.line) : stamp(rule);
}
return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`);
@@ -136,6 +137,8 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre
export interface ReceiptOptions {
repoRoot: string; gated: string; current?: string; policy: ShipPolicy; policyLabel: string; versionRel?: string;
/** The base ref the gated head's own lines are read against (the fork-version bound, GST-963); without it every line keeps the old version's bound. */
baseRef?: string;
predecessor?: { pr: string | null; recordedHead: string | null; ref?: string | null; currentHead?: string | null };
bundlePath?: string; gh?: (args: string[]) => { ok: boolean; out: string }; env?: NodeJS.ProcessEnv; rerunCommand?: string;
}
@@ -176,7 +179,7 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string;
return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' };
}
const diffText = d.stdout.replace(/\n$/, '');
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV);
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV, o.baseRef && oldV ? branchSides(o.repoRoot, o.baseRef, gatedHead, oldV) : undefined);
const residual = files.filter(f => f.verdict === 'changed').map(f => f.path);
const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path);
const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : '';
+13
View File
@@ -218,6 +218,19 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit
expect(stampOnlyText({ before: 'left-pad since: 1.2.3\n', after: 'left-pad since: 1.2.4.0\n', oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false);
});
// GST-963: the carry shares the stage's bound, so a key version the branch added at its fork version (older than the
// pin's VERSION after a base merge) is a stamp on the branch's own line and a hand edit on any other.
test('stampOnlyText takes the fork-version bound only on a line the branch added', () => {
const templates = ["_KEY_VERSION = '{version}'"];
const mine = "export const MINE_KEY_VERSION = '1.2.3.0';";
const before = `${mine}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n`;
const moved = (text: string) => ({ before, after: text, oldVersion: '1.2.4.0', newVersion: '1.2.5.0', templates });
const side = { fork: '1.2.3.0', added: new Set([mine]) };
expect(stampOnlyText({ ...moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))), side })).toBe(true);
expect(stampOnlyText(moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))))).toBe(false);
expect(stampOnlyText({ ...moved(before.replace("BASE_KEY_VERSION = '1.2.3.0'", "BASE_KEY_VERSION = '1.2.5.0'")), side })).toBe(false);
});
test('planCarry names at most CARRY_NAMED_PATHS paths', () => {
const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`);
const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' });
+2 -1
View File
@@ -17,7 +17,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { mergeChangelogs } from '../lib/changelog-check';
import { branchAddedLines, stampOnlyLine } from '../lib/restamp';
import { branchAddedLines } from '../lib/restamp';
import { stampOnlyLine } from '../lib/stamp-only';
import { gitArgvIn } from './helpers/scratch-repo';
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
+51 -2
View File
@@ -15,7 +15,7 @@ import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
import type { GitFixtureTemplate } from './helpers/git-fixture-template';
import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
@@ -88,6 +88,11 @@ const notice = (rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].
function policy(templates: boolean) {
return { ...BASE_POLICY, release_tool: null, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', NOTICE], ...(templates ? { stamp_patterns: ["since: '{version}'"] } : {}) };
}
/** The policy plus the lockfile --next stamps, so the tree receipt's stamp_files allowlist covers the whole stamp commit. */
function receiptPolicy(templates: boolean) {
const p = policy(templates);
return { ...p, stamp_files: [...p.stamp_files, 'package-lock.json'] };
}
function write(repo: FixtureRepo, rel: string, text: string): void {
fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true });
fs.writeFileSync(path.join(repo.work, rel), text);
@@ -243,6 +248,18 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => {
expect(JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json')).status).toBe('complete');
});
// Value: protects=the lost-slot stamp the stage writes is one the train's tree receipt accepts (GST-963 review: one bound
// for the writer and the checks); fails_when=the tree receipt reads the moved own row as a product change; seam=stampOnlyLine
test('the lost-slot stamp passes the tree receipt', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-receipt-', policy: receiptPolicy(true) }); repos.push(repo);
lostSlot(repo);
const gated = repo.git('rev-parse', 'HEAD');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp mine');
const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']);
expect(receipt.out, receipt.err).toContain('TREE: same-modulo-stamps');
});
test('--was still rewrites every line, the base\'s rows included: it is not the fix for this state', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-was-', policy: policy(true) }); repos.push(repo);
lostSlot(repo);
@@ -260,7 +277,7 @@ describe('a branch-added row left at the fork version after the base moved (GST-
// fork version" and leaves the row at the fork version (gbrain #8425), or moves a base row or a pre-fork row;
// seam=lib/restamp.ts rewriteStampPaths; no_claim=finish's route (test/ship-rereview-triggers.test.ts)
test.each([['with the since: template', true], ['built-in quoted form alone', false]])('%s: the fork-version row moves, the base rows and an older branch row stay', (_, templates) => {
const repo = makeFixtureRepo({ prefix: 'restamp-fork-row-', policy: policy(templates) }); repos.push(repo);
const repo = makeFixtureRepo({ prefix: 'restamp-fork-row-', policy: receiptPolicy(templates) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
@@ -275,9 +292,41 @@ describe('a branch-added row left at the fork version after the base moved (GST-
repo.git('checkout', '-q', 'pr-mine');
mergeMain(repo);
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
const gated = repo.git('rev-parse', 'HEAD');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![1]).toBe('done');
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', row('1.2.2.0', 'older'), '', row('1.2.5.0', 'mine')]));
repo.git('commit', '-qam', 'stamp mine');
const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']);
expect(receipt.out, receipt.err).toContain('TREE: same-modulo-stamps');
});
// Value: protects=the four stamp checks share one bound, so a stamp the writer makes is one the train accepts;
// fails_when=the tree receipt (stamp, verify, gate-ahead replay) reads a moved fork-version row, or a base row at that
// version moved by hand, the same way; seam=lib/restamp.ts stampOnlyLine with the branch side
test('the tree receipt accepts the moved branch row and refuses the same move on a base row', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-fork-row-receipt-', policy: receiptPolicy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.3.0', 'mine')]));
repo.git('commit', '-qam', 'mine: a row at the fork version');
makePr(repo, 'pr-other');
write(repo, 'other.txt', 'other\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'other');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp other');
mergeToMain(repo, 'pr-other');
repo.git('checkout', '-q', 'pr-mine');
mergeMain(repo);
const gated = repo.git('rev-parse', 'HEAD');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
write(repo, NOTICE, readFile(repo, NOTICE).replace(row('1.2.3.0', 'base_row'), row('1.2.5.0', 'base_row')));
repo.git('commit', '-qam', 'stamp mine, and move the base row by hand');
const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']);
expect(receipt.out).toContain('TREE: changed');
expect(receipt.out).toContain("id: 'base_row'");
expect(receipt.out).not.toContain("id: 'mine'");
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ 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 { stampOnlyLine } from '../lib/restamp';
import { stampOnlyLine } from '../lib/stamp-only';
import { BASE_POLICY, cleanup, ghShim, makeFixtureRepo, makePr, readFile, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];