From 0ead1ce50d34ba837ea6cc8e765dc26b124963dc Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 10:40:28 +0000 Subject: [PATCH] fix(train): the train stamp and verify checks read the slot predecessor as the base (GST-963) The train restamp moves a branch-added row left at the predecessor version, so stampMismatch and the three train-verify receipts pass baseRef (the gated commit first parent), and the tree receipt applies the branch side even when the slot keeps its claimed VERSION. Failing-first: test/ship-queue-train-stamp.test.ts refused with QUEUE_TRAIN_STAMP_MISMATCH before this commit; on main the pin stamps but the row stays stale. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gst-963-fork-version-stamp.md | 3 ++- docs/ship-policy.md | 2 +- lib/ship-queue-train-stamp.ts | 2 +- lib/ship-queue-train-verify.ts | 6 +++--- lib/tree-receipt.ts | 5 +++-- test/restamp-mentions.test.ts | 2 +- test/ship-queue-train-stamp.test.ts | 15 +++++++++++++++ 7 files changed, 26 insertions(+), 9 deletions(-) diff --git a/changelog.d/gst-963-fork-version-stamp.md b/changelog.d/gst-963-fork-version-stamp.md index 3ba09de5c..5bb21934a 100644 --- a/changelog.d/gst-963-fork-version-stamp.md +++ b/changelog.d/gst-963-fork-version-stamp.md @@ -3,9 +3,10 @@ ### 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. +- **One stamp bound for the writer and every check** (`lib/stamp-only.ts` `stampOnlyLine` with the branch's side): the tree receipt behind `gstack-ship-queue stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (read against the slot's predecessor), 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. This also holds when the branch's own VERSION already equals the new version, as it does in a train slot that kept its claim. ### 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. +- `test/ship-queue-train-stamp.test.ts`: a pin claiming 1.2.4.0 with a row left at the base's 1.2.3.0 stamps, and the row moves to 1.2.4.0. Without the train checks' bound, `stampChain` refused it with `QUEUE_TRAIN_STAMP_MISMATCH`. On main it stamped, but the row stayed at 1.2.3.0. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 592756c6c..4236b1e34 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1523,7 +1523,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`: 1. **version**: `gstack-version-bump write --version ` 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 ``, 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/` 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 `` (`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 `, 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 (: 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 (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. 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. +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 `, 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 (: 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 (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. 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 merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), 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). diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts index 19549fe83..340a98568 100644 --- a/lib/ship-queue-train-stamp.ts +++ b/lib/ship-queue-train-stamp.ts @@ -42,7 +42,7 @@ export function isReleasePath(root: string, policy: ShipPolicy, versionRel: stri * byte for byte, and equals T_i outside release paths. Null when all hold, else why not. */ export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null { - const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel }); + const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel, baseRef: `${merged}^1` }); if ('error' in r) return r.error; if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`; const cl = o.policy.changelog; diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 5c7981e00..53119465f 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -34,7 +34,7 @@ import { genericBackstop } from './ship-queue-train-gate'; const Q = 'gstack-ship-queue'; function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same: boolean; detail: string } { - const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${gated}^1` }); if ('error' in r) return { same: false, detail: r.error }; return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` }; } @@ -79,7 +79,7 @@ function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: const after = t.prefixes[i + 1]!; const root = env.ctx.repoRoot; if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` }; - const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` }); if ('error' in r) return { same: false, detail: r.error }; const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`; if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` }; @@ -105,7 +105,7 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa const post = sameModuloStamps(env, after.commit, main); if (post.same) return post; const cl = env.policy.policy.changelog; - const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` }); if ('error' in r || !cl || r.residual.some(f => f !== cl)) return post; const parent = git(env.ctx.repoRoot, ['rev-parse', '--verify', '-q', `${main}^`]).out; if (!parent || !sameModuloStamps(env, t.prefixes[i]!.commit, parent).same) return { same: false, detail: `${post.detail}; main's parent ${parent.slice(0, 12) || '(none)'} is not T${t.prefixes[i]!.index} modulo stamps` }; diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index 39f10b1d8..7ba350205 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -128,8 +128,9 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre return changed('changelog', folded.length ? 'entry text is not exactly the deleted fragments folded under the new heading' : 'entry text changed (only a heading move or re-heading is a stamp)'); } 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, side?.(f.path)); + const own = rule ? side?.(f.path) : undefined; + if (oldV && newV && (oldV !== newV || own) && rule) { + const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own); return bad ? changed(rule, bad.why, bad.line) : stamp(rule); } return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`); diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index c48740cec..3913d7e92 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -304,7 +304,7 @@ describe('a branch-added row left at the fork version after the base moved (GST- // 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 + // version moved by hand, the same way; seam=lib/stamp-only.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')])); diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts index 8ae814052..4dd9f7f27 100644 --- a/test/ship-queue-train-stamp.test.ts +++ b/test/ship-queue-train-stamp.test.ts @@ -136,4 +136,19 @@ describe('stamped train prefixes', () => { const product = tamper('a.txt', () => 'not a\n'); expect(stampMismatch(o, merged, product, o.plain[2]!.tree)).toContain('a.txt'); }, 60_000); + + // Value: protects=the train's stamp check bounds a branch-added row the same way the train restamp writes it, so a pin + // with a row left at the fork version still stamps; fails_when=stampMismatch reads that row against the pin's own + // VERSION and refuses the moved row; seam=lib/ship-queue-train-stamp.ts stampMismatch baseRef (GST-963) + test('a pin claiming 1.2.4.0 with a stamp_paths row left at the base 1.2.3.0 stamps, and the row moves (GST-963)', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + pin(r, 'f', 'origin/main', '- f change\n'); + fs.writeFileSync(path.join(r.work, 'VERSION'), '1.2.4.0\n'); + fs.writeFileSync(path.join(r.work, 'docs', 'f.md'), '// since: 1.2.3.0\n'); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'f: claims 1.2.4.0, row at the fork version'); + const out = stampChain(chain(r, [['1', r.git('rev-parse', 'HEAD')]])); + if ('code' in out) throw new Error(`${out.code}: ${out.message}`); + expect(out.stamped.map(s => s.version)).toEqual(['1.2.4.0']); + expect(show(r, out.stamped[0]!.commit + ':docs/f.md')).toBe('// since: 1.2.4.0'); + }, 60_000); });